Compare commits

..
2 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5.5 21a53c4159 fix(ingestion): confine /ingest-path and brain_ingest paths to brain/raw/
CI / Lint / Test / Vet (pull_request) Successful in 35s
CI / Mirror to GitHub (pull_request) Skipped
Both read a caller-chosen server path with extract.Text, so any file the
pod could read (the ServiceAccount token, /etc/passwd) could be ingested
into the brain and read back with brain_query.

ResolveIngestPath resolves relative paths against brain/raw/ (the inbox
the watcher already owns), evaluates symlinks, and refuses anything
that lands outside. A directory ingest re-resolves every walked file,
so a symlinked file inside raw/ cannot lead out. Existing ingest-path
tests moved their fixtures under brain/raw/.

Refs #149

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:17:45 +02:00
mathiasandClaude Opus 5.5 99fedeaa98 fix(ingestion): authenticate the REST API as a whole, not route by route
Only /mcp was wrapped in BearerMiddleware; /query, /write, /ingest*,
/promote, /pending and the rest on the same mux had no auth, and
infra#517 found them on the internet (mitigated there by narrowing the
ingress). They are still open on the tailnet and LAN NodePort 30330.

mountREST puts the REST routes on their own mux, mounted at "/" behind
the same bearer middleware and credential as /mcp. Routes registered on
the outer mux stay explicitly public (/mcp has its own auth, /capture,
the HMAC webhook, OAuth metadata, /metrics); anything else, including a
route added later, is authenticated by default.

The hyperguild CLI (cmd/hyperguild and internal/brain) now sends
BRAIN_MCP_TOKEN, which it never did. brain-ui and brain-ingest-ntfy
already send theirs.

Refs #149

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:17:45 +02:00
27 changed files with 127 additions and 1466 deletions
+1 -5
View File
@@ -64,11 +64,7 @@ the routing pod; brain tools moved to the brain MCP.
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`, The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300 `/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`** for shell scripts and non-MCP clients.
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
(infra#517): use the tailnet (`koala:8443`) or in-cluster
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
accept only paths inside `brain/raw/`.
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai `brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title, gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
+2 -6
View File
@@ -304,7 +304,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|---------|-------|-------| |---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same | | **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` | | **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | | **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping — the brain holds no confidential client content.** Nothing on the read path - **Scoping — the brain holds no confidential client content.** Nothing on the read path
@@ -493,11 +493,7 @@ the routing pod; brain tools moved to the brain MCP.
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`, The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300 `/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`** for shell scripts and non-MCP clients.
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
(infra#517): use the tailnet (`koala:8443`) or in-cluster
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
accept only paths inside `brain/raw/`.
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai `brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title, gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
+2 -6
View File
@@ -299,7 +299,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|---------|-------|-------| |---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same | | **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` | | **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | | **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping — the brain holds no confidential client content.** Nothing on the read path - **Scoping — the brain holds no confidential client content.** Nothing on the read path
@@ -488,11 +488,7 @@ the routing pod; brain tools moved to the brain MCP.
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`, The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300 `/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`** for shell scripts and non-MCP clients.
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
(infra#517): use the tailnet (`koala:8443`) or in-cluster
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
accept only paths inside `brain/raw/`.
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai `brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title, gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
+1 -5
View File
@@ -64,11 +64,7 @@ the routing pod; brain tools moved to the brain MCP.
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`, The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300 `/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`** for shell scripts and non-MCP clients.
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
(infra#517): use the tailnet (`koala:8443`) or in-cluster
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
accept only paths inside `brain/raw/`.
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai `brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title, gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
+9 -8
View File
@@ -64,7 +64,9 @@ func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*Quer
return nil, fmt.Errorf("build request: %w", err) return nil, fmt.Errorf("build request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.do(req) c.authorize(req)
resp, err := c.http.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("brain POST /query: %w", err) return nil, fmt.Errorf("brain POST /query: %w", err)
} }
@@ -106,7 +108,9 @@ func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, co
return nil, fmt.Errorf("build request: %w", err) return nil, fmt.Errorf("build request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.do(req) c.authorize(req)
resp, err := c.http.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("brain POST /write: %w", err) return nil, fmt.Errorf("brain POST /write: %w", err)
} }
@@ -143,7 +147,7 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass
if err != nil { if err != nil {
return nil, fmt.Errorf("build request: %w", err) return nil, fmt.Errorf("build request: %w", err)
} }
resp, err := c.do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("brain GET /pass-rate: %w", err) return nil, fmt.Errorf("brain GET /pass-rate: %w", err)
} }
@@ -159,12 +163,9 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass
return &out, nil return &out, nil
} }
// do sends every brain request, adding the bearer token when one is // authorize adds the bearer token when one is configured.
// configured. All methods go through here so none can forget it (PassRate func (c *brainClient) authorize(req *http.Request) {
// did, PR #152 review).
func (c *brainClient) do(req *http.Request) (*http.Response, error) {
if c.token != "" { if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Authorization", "Bearer "+c.token)
} }
return c.http.Do(req)
} }
+3 -10
View File
@@ -13,17 +13,13 @@ import (
// hyperguild#149: the ingestion REST API now requires a bearer token; the CLI // hyperguild#149: the ingestion REST API now requires a bearer token; the CLI
// sends BRAIN_MCP_TOKEN, or it gets 401 on every brain call. // sends BRAIN_MCP_TOKEN, or it gets 401 on every brain call.
func TestBrainClientSendsBrainMCPTokenOnQueryWriteAndPassRate(t *testing.T) { func TestBrainClientSendsBrainMCPToken(t *testing.T) {
var got []string var got []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.URL.Path+" "+r.Header.Get("Authorization")) got = append(got, r.URL.Path+" "+r.Header.Get("Authorization"))
switch r.URL.Path { if r.URL.Path == "/write" {
case "/write":
_, _ = w.Write([]byte(`{"path":"wiki/x/facts/y.md"}`)) _, _ = w.Write([]byte(`{"path":"wiki/x/facts/y.md"}`))
return return
case "/pass-rate":
_, _ = w.Write([]byte(`{}`))
return
} }
_, _ = w.Write([]byte(`{"results":[]}`)) _, _ = w.Write([]byte(`{"results":[]}`))
})) }))
@@ -36,10 +32,7 @@ func TestBrainClientSendsBrainMCPTokenOnQueryWriteAndPassRate(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
_, err = c.Write(context.Background(), "x", "facts", "y", strings.NewReader("# y")) _, err = c.Write(context.Background(), "x", "facts", "y", strings.NewReader("# y"))
require.NoError(t, err) require.NoError(t, err)
// PR #152 review: PassRate sent no token and would 401 in production. assert.Equal(t, []string{"/query Bearer cli-test-token", "/write Bearer cli-test-token"}, got)
_, _ = c.PassRate(context.Background(), "tdd", "7d")
assert.Equal(t, []string{"/query Bearer cli-test-token", "/write Bearer cli-test-token",
"/pass-rate Bearer cli-test-token"}, got)
} }
func TestBrainClientSendsNoAuthorizationWhenTokenUnset(t *testing.T) { func TestBrainClientSendsNoAuthorizationWhenTokenUnset(t *testing.T) {
@@ -24,7 +24,7 @@ Only a `tools/call` to the routing pod records a pass/fail.
|---------|-----|------| |---------|-----|------|
| Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` | | Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` |
| Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) | | Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) |
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | Bearer `BRAIN_MCP_TOKEN` (since hyperguild#149) | | Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | none (read-only) |
Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures), Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures),
plus `session_log`, `retrospective`, `trainer`. plus `session_log`, `retrospective`, `trainer`.
+16 -28
View File
@@ -33,7 +33,6 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker" "github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
"github.com/mathiasbq/hyperguild/ingestion/internal/search" "github.com/mathiasbq/hyperguild/ingestion/internal/search"
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry" "github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
"github.com/mathiasbq/hyperguild/ingestion/internal/symlinkguard"
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore" "github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher" "github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook" "github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
@@ -174,8 +173,7 @@ func alertingWarnings(getenv func(string) string) []string {
var out []string var out []string
if getenv("BRAIN_NTFY_URL") == "" { if getenv("BRAIN_NTFY_URL") == "" {
out = append(out, "alerting DISABLED: BRAIN_NTFY_URL unset — "+ out = append(out, "alerting DISABLED: BRAIN_NTFY_URL unset — "+
"brain_edit_root governance rewrites, capture audit degradation and the "+ "brain_edit_root governance rewrites and capture audit degradation will not notify")
"brain symlink tripwire (hyperguild#157) will not notify")
} }
return out return out
} }
@@ -241,10 +239,6 @@ func main() {
} }
brainDir := envOr("INGEST_BRAIN_DIR", "../brain") brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
if err := startupSymlinkCheck(brainDir); err != nil {
logger.Error("brain symlink tripwire", "err", err)
os.Exit(1)
}
port := envOr("INGEST_PORT", "3300") port := envOr("INGEST_PORT", "3300")
llmURL := envOr("INGEST_LLM_URL", "http://iguana:4000/v1") llmURL := envOr("INGEST_LLM_URL", "http://iguana:4000/v1")
@@ -366,8 +360,6 @@ func main() {
} }
ctx := context.Background() ctx := context.Background()
// hyperguild#157: report a symlink that appears in the brain while running.
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier())
// Built before the watcher, the graph backfill and session ingestion, and // Built before the watcher, the graph backfill and session ingestion, and
// retried rather than fatal (#83). Two separate reasons: // retried rather than fatal (#83). Two separate reasons:
@@ -511,10 +503,9 @@ func main() {
logger.Info("embed sync started", "interval_s", embedSyncInterval) logger.Info("embed sync started", "interval_s", embedSyncInterval)
} }
// Public routes only; buildMux mounts the authenticated REST API (#149). mux := http.NewServeMux()
public := map[string]http.Handler{}
if webhookHandler != nil { if webhookHandler != nil {
public["POST /webhooks/brain-sync"] = webhookHandler mux.Handle("POST /webhooks/brain-sync", webhookHandler)
} }
// Resource-metadata URL is only emitted on 401 when Dex OAuth is // Resource-metadata URL is only emitted on 401 when Dex OAuth is
// configured. Static-Bearer-only deployments leave this empty so // configured. Static-Bearer-only deployments leave this empty so
@@ -522,14 +513,21 @@ func main() {
var resourceMetadataURL string var resourceMetadataURL string
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" { if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
resourceURL := os.Getenv("MCP_RESOURCE_URL") resourceURL := os.Getenv("MCP_RESOURCE_URL")
public["GET /.well-known/oauth-protected-resource"] = http.HandlerFunc( mux.HandleFunc("GET /.well-known/oauth-protected-resource",
chassisauth.ProtectedResourceHandler(resourceURL, dexURL)) chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
if resourceURL != "" { if resourceURL != "" {
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource" resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
} }
} }
public["/mcp"] = chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv) mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
// The REST API, authenticated as a whole (hyperguild#149). Same credential
// as /mcp: callers (brain-ui, brain-ingest-ntfy, the hyperguild CLI) send
// BRAIN_MCP_TOKEN. Registered after the public routes on purpose only for
// reading order; ServeMux precedence is by pattern specificity.
mountREST(mux, h, func(next http.Handler) http.Handler {
return chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, next)
})
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket // POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
// tracker to file action items, so it only mounts when Gitea is // tracker to file action items, so it only mounts when Gitea is
@@ -551,7 +549,7 @@ func main() {
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS")) sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
resolver := capturehttp.NewOriginResolver(sovereign) resolver := capturehttp.NewOriginResolver(sovereign)
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver) captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
public["POST /capture"] = captureH mux.Handle("POST /capture", captureH)
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native // Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through // harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
// the existing /mcp OAuth connector. mcpSrv is already wrapped above; // the existing /mcp OAuth connector. mcpSrv is already wrapped above;
@@ -581,9 +579,9 @@ func main() {
logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer") logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer")
os.Exit(1) os.Exit(1)
} }
public["GET /.well-known/oauth-authorization-server"] = http.HandlerFunc( mux.HandleFunc("GET /.well-known/oauth-authorization-server",
oauth.MetadataHandler(issuer)) oauth.MetadataHandler(issuer))
public["POST /oauth/token"] = http.HandlerFunc(oauth.TokenHandler(oauth.TokenConfig{ mux.HandleFunc("POST /oauth/token", oauth.TokenHandler(oauth.TokenConfig{
ClientID: oauthID, ClientID: oauthID,
ClientSecret: oauthSecret, ClientSecret: oauthSecret,
AccessToken: mcpToken, AccessToken: mcpToken,
@@ -602,17 +600,7 @@ func main() {
// real request latency. /metrics itself is excluded from its own // real request latency. /metrics itself is excluded from its own
// observation by registering it on the outer mux (post-wrap). // observation by registering it on the outer mux (post-wrap).
reg := metrics.New() reg := metrics.New()
public["GET /metrics"] = http.HandlerFunc(reg.Handler()) mux.HandleFunc("GET /metrics", reg.Handler())
// The whole router, built in one place so the test of buildMux is a test
// of production wiring (PR #152 review). The REST API's credential is the
// same as /mcp's.
mux, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain",
resourceMetadataURL: resourceMetadataURL}, h, public)
if err != nil {
logger.Error("build router", "err", err)
os.Exit(1)
}
logger.Info("metrics endpoint registered", "path", "/metrics") logger.Info("metrics endpoint registered", "path", "/metrics")
addr := ":" + port addr := ":" + port
+6 -53
View File
@@ -1,65 +1,18 @@
package main package main
import ( import (
"errors"
"fmt"
"net/http" "net/http"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/api"
) )
// publicRoutes is the complete set of patterns allowed on the outer mux,
// outside the REST API's auth (hyperguild#149). Each authenticates itself or is
// meant to be open: /mcp (bearer/JWT), /capture (bearer/JWT), the webhook
// (HMAC), /oauth/token (client secret), OAuth metadata and /metrics (open).
// A REST pattern added here would beat the authenticated catch-all by
// specificity and ship open, which is why buildMux refuses anything else.
var publicRoutes = map[string]bool{
"/mcp": true,
"POST /capture": true,
"POST /webhooks/brain-sync": true,
"GET /.well-known/oauth-protected-resource": true,
"GET /.well-known/oauth-authorization-server": true,
"POST /oauth/token": true,
"GET /metrics": true,
}
// restAuth is the credential the REST API checks: the same as /mcp's.
type restAuth struct {
token string
validator *chassisauth.JWTValidator
realm string
resourceMetadataURL string
}
// buildMux is the server's whole router. The REST API is mounted at "/"
// behind bearer auth that buildMux constructs itself, so a caller cannot pass
// a no-op; public handlers are registered as given, and only for patterns on
// the publicRoutes allowlist. It returns an http.Handler, not the
// *ServeMux, so nothing can register a route on the result afterwards and
// bypass the allowlist.
func buildMux(auth restAuth, h *api.Handler, public map[string]http.Handler) (http.Handler, error) {
if auth.token == "" {
return nil, errors.New("buildMux: REST auth needs a token")
}
mux := http.NewServeMux()
for pattern, handler := range public {
if !publicRoutes[pattern] {
return nil, fmt.Errorf("buildMux: %q is not an allowed public route", pattern)
}
mux.Handle(pattern, handler)
}
mountREST(mux, h, func(next http.Handler) http.Handler {
return chassisauth.BearerMiddleware(auth.token, auth.validator, auth.realm, auth.resourceMetadataURL, next)
})
return mux, nil
}
// mountREST registers the REST API on its own mux and mounts it at "/" behind // mountREST registers the REST API on its own mux and mounts it at "/" behind
// auth: anything not registered as public lands here and is authenticated, // auth (hyperguild#149). Authentication is a property of the mux, not of each
// including routes added later. // route: anything not registered on the outer mux as explicitly public (/mcp,
// /capture, the HMAC webhook, OAuth metadata, /metrics) lands here and is
// authenticated, including routes added later. Before this, only /mcp was
// wrapped, and the REST routes beside it were open; infra#517 found them on
// the internet.
func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.Handler) { func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.Handler) {
rest := http.NewServeMux() rest := http.NewServeMux()
rest.HandleFunc("POST /query", h.Query) rest.HandleFunc("POST /query", h.Query)
+29 -61
View File
@@ -9,7 +9,8 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
@@ -17,27 +18,22 @@ import (
const routesTok = "routes-test-token" const routesTok = "routes-test-token"
func okHandler(body string) http.Handler { // hyperguild#149: only /mcp was authenticated; every REST route on the same
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) // mux was open, and infra#517 found them on the internet. The REST API is now
} // mounted at "/" behind the bearer middleware, so any route not registered
// as explicitly public is authenticated, including routes added later.
// testMux builds the router exactly as main does: buildMux with the full func testRouter(t *testing.T) http.Handler {
// public set main registers (stub handlers) and real bearer config.
// hyperguild#149: the REST API is authenticated as a whole; the PR #152
// review showed a test of a COPY of the router let main.go regress unseen.
func testMux(t *testing.T) http.Handler {
t.Helper() t.Helper()
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{}) h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
public := map[string]http.Handler{} mux := http.NewServeMux()
for p := range publicRoutes { mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
public[p] = okHandler("public " + p) mountREST(mux, h, func(next http.Handler) http.Handler {
} return chassisauth.BearerMiddleware(routesTok, nil, "brain", "", next)
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, public) })
require.NoError(t, err)
return mux return mux
} }
func do(t *testing.T, h http.Handler, method, path, authz string) (int, string) { func do(t *testing.T, h http.Handler, method, path, authz string) int {
t.Helper() t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`)) req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`))
if authz != "" { if authz != "" {
@@ -45,62 +41,34 @@ func do(t *testing.T, h http.Handler, method, path, authz string) (int, string)
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
return rec.Code, rec.Body.String() return rec.Code
} }
func TestBuildMuxRefusesRESTRoutesWithoutAValidToken(t *testing.T) { func TestRESTRoutesRefuseRequestsWithoutAToken(t *testing.T) {
r := testMux(t) r := testRouter(t)
for _, rt := range []struct{ method, path string }{ for _, rt := range []struct{ method, path string }{
{"POST", "/query"}, {"POST", "/write"}, {"POST", "/index"}, {"POST", "/ingest"}, {"POST", "/query"}, {"POST", "/write"}, {"POST", "/index"}, {"POST", "/ingest"},
{"POST", "/ingest-path"}, {"POST", "/ingest-raw"}, {"POST", "/backfill-refs"}, {"POST", "/ingest-path"}, {"POST", "/ingest-raw"}, {"POST", "/backfill-refs"},
{"GET", "/pending"}, {"POST", "/promote"}, {"POST", "/backfill-embeddings"}, {"GET", "/pass-rate"}, {"GET", "/pending"}, {"POST", "/promote"}, {"POST", "/backfill-embeddings"}, {"GET", "/pass-rate"},
} { } {
code, _ := do(t, r, rt.method, rt.path, "") assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, ""), "%s %s without a token", rt.method, rt.path)
assert.Equal(t, http.StatusUnauthorized, code, "%s %s without a token", rt.method, rt.path) assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, "Bearer wrong"), "%s %s with a wrong token", rt.method, rt.path)
code, _ = do(t, r, rt.method, rt.path, "Bearer wrong")
assert.Equal(t, http.StatusUnauthorized, code, "%s %s with a wrong token", rt.method, rt.path)
} }
} }
func TestBuildMuxServesRESTRoutesToAValidToken(t *testing.T) { func TestRESTRoutesServeAValidToken(t *testing.T) {
r := testMux(t) r := testRouter(t)
code, _ := do(t, r, "GET", "/pending", "Bearer "+routesTok) assert.NotEqual(t, http.StatusUnauthorized, do(t, r, "POST", "/query", "Bearer "+routesTok))
assert.Equal(t, http.StatusOK, code) assert.Equal(t, http.StatusOK, do(t, r, "GET", "/pending", "Bearer "+routesTok))
} }
func TestBuildMuxAuthenticatesAnUnregisteredPath(t *testing.T) { // Default-deny: a path nobody registered is still behind auth, so a route
code, _ := do(t, testMux(t), "POST", "/some-future-route", "") // added to the REST mux later cannot ship open by omission.
assert.Equal(t, http.StatusUnauthorized, code) func TestUnregisteredPathIsAuthenticatedByDefault(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, do(t, testRouter(t), "POST", "/some-future-route", ""))
} }
// buildMux registers public handlers as given, without adding auth: each // Explicitly public routes registered on the outer mux keep working.
// public route owns its own (bearer on /mcp, HMAC on the webhook, client func TestMetricsStaysOpenForPrometheus(t *testing.T) {
// secret on /oauth/token) or is meant to be open (/metrics, OAuth metadata). assert.Equal(t, http.StatusOK, do(t, testRouter(t), "GET", "/metrics", ""))
func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
r := testMux(t)
for p := range publicRoutes {
method, path, ok := strings.Cut(p, " ")
if !ok {
method, path = "GET", p
}
code, body := do(t, r, method, path, "")
assert.Equal(t, http.StatusOK, code, p)
assert.Equal(t, "public "+p, body, p)
}
}
// The allowlist is the control: a REST pattern registered as public would win
// over the authenticated catch-all (more specific pattern), so it is refused.
func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) {
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics"} {
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, map[string]http.Handler{p: okHandler("x")})
assert.Error(t, err, "public pattern %q must be refused", p)
}
}
func TestBuildMuxRefusesAnEmptyToken(t *testing.T) {
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
_, err := buildMux(restAuth{token: "", realm: "brain"}, h, nil)
assert.Error(t, err)
} }
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"errors"
"fmt"
"os"
"strconv"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/symlinkguard"
)
// startupSymlinkCheck is the hyperguild#157 tripwire at boot: a symlink in the
// brain dir would let brain readers serve a file from outside the brain, so
// the server does not start until it is removed. A directory the scan cannot
// read could hide one, so it refuses on that too. A missing brain dir (first
// boot) is the only case it lets pass.
func startupSymlinkCheck(brainDir string) error {
r, err := symlinkguard.Scan(brainDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("refusing to start: cannot scan the brain dir %s (hyperguild#157): %w", brainDir, err)
}
if !r.Clean() {
return fmt.Errorf("refusing to start: brain dir %s has %s (hyperguild#157); fix them first", brainDir, r)
}
return nil
}
// symlinkScanInterval reads BRAIN_SYMLINK_SCAN_INTERVAL (seconds), falling
// back to the default on an unset, non-numeric or non-positive value.
func symlinkScanInterval(getenv func(string) string) time.Duration {
if s, err := strconv.Atoi(getenv("BRAIN_SYMLINK_SCAN_INTERVAL")); err == nil && s > 0 {
return time.Duration(s) * time.Second
}
return symlinkguard.DefaultInterval
}
@@ -1,65 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#157: the server refuses to start while a symlink exists in the
// brain dir, or while any part of it cannot be scanned (which could hide
// one); a missing brain dir is the only thing it lets pass.
func TestStartupSymlinkCheckRefusesABrainWithASymlink(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "planted")))
err := startupSymlinkCheck(brain)
require.Error(t, err)
assert.Contains(t, err.Error(), "planted")
}
func TestStartupSymlinkCheckRefusesABrainWithAnUnreadableDir(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads mode-000 directories")
}
brain := t.TempDir()
locked := filepath.Join(brain, "aaa")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
err := startupSymlinkCheck(brain)
require.Error(t, err)
assert.Contains(t, err.Error(), "aaa")
}
func TestStartupSymlinkCheckPassesACleanOrMissingBrain(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(brain, "n.md"), []byte("x"), 0o644))
assert.NoError(t, startupSymlinkCheck(brain))
assert.NoError(t, startupSymlinkCheck(filepath.Join(brain, "missing")))
}
func TestSymlinkScanIntervalFallsBackOnNonPositive(t *testing.T) {
for _, v := range []string{"0", "-5", "", "junk"} {
got := symlinkScanInterval(func(string) string { return v })
assert.Positive(t, got, "env %q", v)
}
}
func TestAlertingWarningsNamesTheSymlinkTripwire(t *testing.T) {
w := strings.Join(alertingWarnings(func(string) string { return "" }), " ")
assert.Contains(t, w, "symlink tripwire")
}
func TestStartupSymlinkCheckRefusesAnUnlistableBrainRoot(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root lists mode-0100 directories")
}
brain := t.TempDir()
require.NoError(t, os.Chmod(brain, 0o100))
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
assert.Error(t, startupSymlinkCheck(brain))
}
+3 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/claim" "github.com/mathiasbq/hyperguild/ingestion/internal/claim"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/search" "github.com/mathiasbq/hyperguild/ingestion/internal/search"
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore" "github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
@@ -480,7 +481,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
allWarnings = append(allWarnings, fmt.Sprintf("skipped %s: %v", path, rerr)) allWarnings = append(allWarnings, fmt.Sprintf("skipped %s: %v", path, rerr))
return nil return nil
} }
content, readErr := ReadIngestFile(h.brainDir, path) content, readErr := extract.Text(path)
if readErr != nil { if readErr != nil {
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr)) allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
return nil return nil
@@ -509,7 +510,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext)) writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
return return
} }
content, readErr := ReadIngestFile(h.brainDir, req.Path) content, readErr := extract.Text(req.Path)
if readErr != nil { if readErr != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr)) writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr))
return return
+6 -36
View File
@@ -2,17 +2,13 @@ package api
import ( import (
"errors" "errors"
"os" "fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
"path/filepath" "path/filepath"
"strings" "strings"
) )
// ErrOutsideIngestRoot is the one refusal for every bad ingest path. It never // ErrOutsideIngestRoot means an ingest path does not resolve inside brain/raw/.
// carries the underlying error: "no such file" versus "permission denied" var ErrOutsideIngestRoot = errors.New("ingest path must be inside brain/raw/")
// would let a caller probe which server files exist (PR #152 review).
var ErrOutsideIngestRoot = errors.New("ingest path must be an existing file or directory inside brain/raw/")
// ResolveIngestPath confines a caller-supplied ingest path to brain/raw/, the // ResolveIngestPath confines a caller-supplied ingest path to brain/raw/, the
// inbox the watcher already owns (hyperguild#149). Relative paths resolve // inbox the watcher already owns (hyperguild#149). Relative paths resolve
@@ -20,23 +16,18 @@ var ErrOutsideIngestRoot = errors.New("ingest path must be an existing file or d
// a link inside raw/ that points elsewhere is refused. The path must exist. // a link inside raw/ that points elsewhere is refused. The path must exist.
func ResolveIngestPath(brainDir, p string) (string, error) { func ResolveIngestPath(brainDir, p string) (string, error) {
if strings.TrimSpace(p) == "" { if strings.TrimSpace(p) == "" {
return "", ErrOutsideIngestRoot return "", fmt.Errorf("%w: path is empty", ErrOutsideIngestRoot)
}
// raw/ itself must be a real directory: if it were a symlink, containment
// inside it would mean nothing (hyperguild#153 item 4).
if fi, lerr := os.Lstat(filepath.Join(brainDir, "raw")); lerr != nil || fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
return "", ErrOutsideIngestRoot
} }
root, err := filepath.EvalSymlinks(filepath.Join(brainDir, "raw")) root, err := filepath.EvalSymlinks(filepath.Join(brainDir, "raw"))
if err != nil { if err != nil {
return "", ErrOutsideIngestRoot return "", fmt.Errorf("%w: inbox unavailable: %v", ErrOutsideIngestRoot, err)
} }
if !filepath.IsAbs(p) { if !filepath.IsAbs(p) {
p = filepath.Join(root, p) p = filepath.Join(root, p)
} }
real, err := filepath.EvalSymlinks(filepath.Clean(p)) real, err := filepath.EvalSymlinks(filepath.Clean(p))
if err != nil { if err != nil {
return "", ErrOutsideIngestRoot return "", fmt.Errorf("%w: %v", ErrOutsideIngestRoot, err)
} }
rel, err := filepath.Rel(root, real) rel, err := filepath.Rel(root, real)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
@@ -44,24 +35,3 @@ func ResolveIngestPath(brainDir, p string) (string, error) {
} }
return real, nil return real, nil
} }
// ReadIngestFile extracts text from an ingest path already confined by
// ResolveIngestPath, reading it through the raw/ root rather than reopening
// the path by name (hyperguild#153 item 3): if the file is swapped for a
// symlink after the check, the read still cannot leave raw/.
func ReadIngestFile(brainDir, resolved string) (string, error) {
root, rawPath, err := OpenRawRoot(brainDir)
if err != nil {
return "", ErrOutsideIngestRoot
}
defer func() { _ = root.Close() }()
realRoot, err := filepath.EvalSymlinks(rawPath)
if err != nil {
return "", ErrOutsideIngestRoot
}
rel, err := filepath.Rel(realRoot, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", ErrOutsideIngestRoot
}
return extract.TextIn(root, rel)
}
@@ -67,22 +67,3 @@ func TestIngestPathDirectoryWalkSkipsSymlinksLeavingTheInbox(t *testing.T) {
assert.Contains(t, rec.Body.String(), "skipped") assert.Contains(t, rec.Body.String(), "skipped")
assert.NotContains(t, rec.Body.String(), "SENTINEL-149-OUTSIDE") assert.NotContains(t, rec.Body.String(), "SENTINEL-149-OUTSIDE")
} }
// PR #152 review: the raw EvalSymlinks error told an authenticated caller
// whether any server file existed ("no such file") or was unreadable
// ("permission denied"), and leaked the brain directory. Every refusal now
// reads the same.
func TestResolveIngestPathRefusalsAreIndistinguishable(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
var msgs []string
for _, p := range []string{"/etc/hostname", "/etc/definitely-not-here-149", "../x", "nope/missing.md", ""} {
_, err := api.ResolveIngestPath(brain, p)
require.Error(t, err, p)
msgs = append(msgs, err.Error())
assert.NotContains(t, err.Error(), brain, "refusal for %q leaks the brain dir", p)
}
for _, m := range msgs[1:] {
assert.Equal(t, msgs[0], m)
}
}
+20 -37
View File
@@ -1,9 +1,7 @@
package api package api
import ( import (
"errors"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -30,25 +28,25 @@ var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
// (natural review order). An absent raw/ dir yields an empty slice, not an // (natural review order). An absent raw/ dir yields an empty slice, not an
// error. Only .md files are listed; tunnel-candidate files are skipped. // error. Only .md files are listed; tunnel-candidate files are skipped.
func ListPending(brainDir string) ([]PendingNote, error) { func ListPending(brainDir string) ([]PendingNote, error) {
root, _, err := OpenRawRoot(brainDir) dir := filepath.Join(brainDir, "raw")
entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if os.IsNotExist(err) {
return []PendingNote{}, nil return []PendingNote{}, nil
} }
return nil, fmt.Errorf("read raw dir: %w", err) return nil, fmt.Errorf("read raw dir: %w", err)
} }
defer func() { _ = root.Close() }()
entries, err := fs.ReadDir(root.FS(), ".")
if err != nil {
return nil, fmt.Errorf("read raw dir: %w", err)
}
out := make([]PendingNote, 0, len(entries)) out := make([]PendingNote, 0, len(entries))
for _, e := range entries { for _, e := range entries {
if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
continue // directories, and symlinks whose target may be anywhere (#153) continue
} }
raw, info, readErr := readRawNote(root, e.Name()) info, statErr := e.Info()
if statErr != nil {
continue
}
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
if readErr != nil { if readErr != nil {
continue continue
} }
@@ -107,9 +105,12 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
} }
src := filepath.Join(brainDir, "raw", base) src := filepath.Join(brainDir, "raw", base)
raw, err := readRawSource(brainDir, base) raw, err := os.ReadFile(src)
if err != nil { if err != nil {
return "", err if os.IsNotExist(err) {
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
}
return "", fmt.Errorf("read source: %w", err)
} }
// Collision: never silently overwrite an existing promoted note. // Collision: never silently overwrite an existing promoted note.
@@ -179,9 +180,12 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
} }
src := filepath.Join(brainDir, "raw", base) src := filepath.Join(brainDir, "raw", base)
raw, err := readRawSource(brainDir, base) raw, err := os.ReadFile(src)
if err != nil { if err != nil {
return "", err if os.IsNotExist(err) {
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
}
return "", fmt.Errorf("read source: %w", err)
} }
dest := filepath.Join(brainDir, "raw", "rejected", base) dest := filepath.Join(brainDir, "raw", "rejected", base)
@@ -220,24 +224,3 @@ func excerpt(s string, n int) string {
} }
return strings.TrimSpace(string(r)) return strings.TrimSpace(string(r))
} }
// readRawSource reads a pending note for promote/reject through the raw root,
// refusing symlinks and a symlinked raw/ (hyperguild#153).
func readRawSource(brainDir, base string) ([]byte, error) {
root, _, err := OpenRawRoot(brainDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
}
return nil, fmt.Errorf("read source: %w", err)
}
defer func() { _ = root.Close() }()
raw, _, err := readRawNote(root, base)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
}
return nil, fmt.Errorf("read source: %w", err)
}
return raw, nil
}
-172
View File
@@ -1,172 +0,0 @@
package api_test
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// hyperguild#153 / PR #156: behavioural race tests, so the control does not
// rest on lints that list forbidden APIs (round 3: an os.ReadFile or
// Root.ReadFile rewrite passed the lint and leaked 19k times in 4s). A
// swapper keeps renaming a regular note, a symlink to an outside file, or a
// FIFO into raw/r.md while the public readers loop. Any leaked byte fails;
// any hang fails.
const raceSentinel = "SENTINEL-153-RACE"
func swap(raw, outside string, fifo bool, stop *atomic.Bool) {
tgt := filepath.Join(raw, "r.md")
for i := 0; !stop.Load(); i++ {
tmp := filepath.Join(raw, ".swap")
_ = os.Remove(tmp)
switch {
case i%2 == 0:
_ = os.WriteFile(tmp, []byte("# r\n\ninside"), 0o644)
case fifo:
_ = syscall.Mkfifo(tmp, 0o644)
default:
_ = os.Symlink(outside, tmp)
}
_ = os.Rename(tmp, tgt)
}
}
func raceBrain(t *testing.T) (brain, raw, outside string) {
t.Helper()
brain = t.TempDir()
raw = filepath.Join(brain, "raw")
if err := os.MkdirAll(raw, 0o755); err != nil {
t.Fatal(err)
}
outside = filepath.Join(t.TempDir(), "secret.md")
if err := os.WriteFile(outside, []byte("# s\n\n"+raceSentinel), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(raw, "r.md"), []byte("# r\n\ninside"), 0o644); err != nil {
t.Fatal(err)
}
return brain, raw, outside
}
// readers runs every public raw/ reader once and returns all text they
// produced (excerpts, extracted text).
func readers(brain, raw string) string {
var b strings.Builder
if ns, err := api.ListPending(brain); err == nil {
for _, n := range ns {
b.WriteString(n.Excerpt)
}
}
if s, err := api.ReadIngestFile(brain, filepath.Join(raw, "r.md")); err == nil {
b.WriteString(s)
}
return b.String()
}
func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) {
brain, raw, outside := raceBrain(t)
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)
}
if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
}
t.Logf("%d iterations, 0 leaks", iters)
}
func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) {
brain, raw, outside := raceBrain(t)
var stop atomic.Bool
go swap(raw, outside, true, &stop)
var iters atomic.Int64
done := make(chan struct{})
go func() {
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
_ = readers(brain, raw)
iters.Add(1)
}
close(done)
}()
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()
}
t.Fatalf("a raw/ reader hung on a swapped-in FIFO after %d iterations", iters.Load())
}
}
// PR #156 round 4: the pdf/docmark branch of TextIn copies the file through
// the root to a temp file; pointing the extractor back at the raw/ path by
// name stayed green because the race tests only used r.md (a stub docmark
// then received the outside file's bytes 34k times in 4s). This races a
// symlink into raw/r.docx and has a fake docmark count the sentinel in every
// document it is sent.
func TestDocmarkExtractionLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
var seen atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Params struct {
Arguments struct {
ContentBase64 string `json:"content_base64"`
} `json:"arguments"`
} `json:"params"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if b, err := base64.StdEncoding.DecodeString(req.Params.Arguments.ContentBase64); err == nil &&
strings.Contains(string(b), raceSentinel) {
seen.Add(1)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"converted"}]}}`))
}))
defer srv.Close()
t.Setenv("DOCMARK_URL", srv.URL)
brain, raw, outside := raceBrain(t)
tgt := filepath.Join(raw, "r.docx")
if err := os.WriteFile(tgt, []byte("inside docx bytes"), 0o644); err != nil {
t.Fatal(err)
}
var stop atomic.Bool
go func() {
for i := 0; !stop.Load(); i++ {
tmp := filepath.Join(raw, ".swapdocx")
_ = os.Remove(tmp)
if i%2 == 0 {
_ = os.WriteFile(tmp, []byte("inside docx bytes"), 0o644)
} else {
_ = os.Symlink(outside, tmp)
}
_ = 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)
}
if n := seen.Load(); n > 0 {
t.Fatalf("docmark received the outside file %d times in %d iterations", n, iters)
}
t.Logf("%d iterations, 0 leaks to docmark", iters)
}
@@ -1,80 +0,0 @@
package api_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// hyperguild#153: readers of brain/raw/ followed symlinks. /pending showed the
// target's contents and promote would copy it into wiki/ (and brain-sync to
// Gitea). raw/ is writable via Syncthing, so a symlink there is reachable
// input. Every raw/ reader now treats a symlink as not-a-note.
const sentinel153 = "SENTINEL-153-OUTSIDE"
func rawWithSymlink(t *testing.T) (brain string) {
t.Helper()
brain = t.TempDir()
raw := filepath.Join(brain, "raw")
require.NoError(t, os.MkdirAll(raw, 0o755))
outside := filepath.Join(t.TempDir(), "secret.md")
require.NoError(t, os.WriteFile(outside, []byte("---\ncreated_at: 2026-01-01T00:00:00Z\n---\n# s\n\n"+sentinel153), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "2026-01-01-leak.md")))
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-01-02-real.md"), []byte("# real\n\nreal body"), 0o644))
return brain
}
func TestListPendingSkipsSymlinkedNotes(t *testing.T) {
notes, err := api.ListPending(rawWithSymlink(t))
require.NoError(t, err)
var names []string
for _, n := range notes {
names = append(names, n.Filename)
assert.NotContains(t, n.Excerpt, sentinel153)
}
assert.Equal(t, []string{"2026-01-02-real.md"}, names)
}
func TestPromoteNoteRefusesASymlinkedSource(t *testing.T) {
brain := rawWithSymlink(t)
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-01-01-leak.md", Wing: "testing", Hall: "facts"})
require.Error(t, err)
_, statErr := os.Stat(filepath.Join(brain, "wiki", "testing", "facts", "leak.md"))
assert.True(t, os.IsNotExist(statErr), "nothing may be promoted from a symlink")
}
func TestRejectNoteRefusesASymlinkedSource(t *testing.T) {
brain := rawWithSymlink(t)
_, err := api.RejectNote(brain, api.RejectOptions{Filename: "2026-01-01-leak.md"})
require.Error(t, err)
b, _ := os.ReadFile(filepath.Join(brain, "raw", "rejected", "2026-01-01-leak.md"))
assert.False(t, strings.Contains(string(b), sentinel153))
}
// Item 4: if raw/ itself is a symlink, containment inside it means nothing.
func TestResolvePendingPromoteRejectAndReadIngestRefuseASymlinkedRawRoot(t *testing.T) {
brain := t.TempDir()
elsewhere := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(elsewhere, "x.md"), []byte("# x\n\n"+sentinel153), 0o644))
require.NoError(t, os.Symlink(elsewhere, filepath.Join(brain, "raw")))
_, err := api.ResolveIngestPath(brain, "x.md")
assert.Error(t, err, "ingest must refuse a symlinked raw/")
notes, err := api.ListPending(brain)
assert.Error(t, err, "pending must refuse a symlinked raw/")
assert.Empty(t, notes)
_, err = api.PromoteNote(brain, api.PromoteOptions{Filename: "x.md", Wing: "testing", Hall: "facts"})
assert.Error(t, err, "promote must refuse a symlinked raw/")
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "x.md"})
assert.Error(t, err, "reject must refuse a symlinked raw/")
text, err := api.ReadIngestFile(brain, filepath.Join(elsewhere, "x.md"))
assert.Error(t, err, "ReadIngestFile must refuse a symlinked raw/")
assert.NotContains(t, text, sentinel153)
// The watcher's refusal is tested in internal/watcher.
}
-67
View File
@@ -1,67 +0,0 @@
package api
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
)
// errNotANote is returned for a raw/ entry that is not a regular file — in
// particular a symlink, whose target may be anywhere (hyperguild#153).
var errNotANote = errors.New("not a regular note file in raw/ (symlinks are refused)")
// errRawNotDir means brain/raw is not a real directory (e.g. a symlink), so
// containment inside it would mean nothing (hyperguild#153 item 4).
var errRawNotDir = errors.New("brain/raw is not a real directory")
// OpenRawRoot opens brain/raw as an os.Root, refusing a raw/ that is itself a
// symlink. Reads through the root cannot leave it, even if an entry is
// swapped for a symlink between a check and the read. It returns the root
// and raw/'s path. A missing raw/ returns an error wrapping os.ErrNotExist.
func OpenRawRoot(brainDir string) (*os.Root, string, error) {
p := filepath.Join(brainDir, "raw")
fi, err := os.Lstat(p)
if err != nil {
return nil, "", err
}
if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
return nil, "", errRawNotDir
}
r, err := os.OpenRoot(p)
if err != nil {
return nil, "", fmt.Errorf("open raw root: %w", err)
}
return r, p, nil
}
// readRawNote reads one raw/ entry by basename through the root, refusing
// anything that is not a regular file.
//
// Accepted residual: a HARD link to an outside file is a regular file and is
// read. Syncthing syncs contents, not links, so a peer cannot create one;
// fs.protected_hardlinks=1 and the pod's secrets live 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 assumption changes.
func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
fi, err := r.Lstat(base)
if err != nil {
return nil, nil, err
}
if !fi.Mode().IsRegular() {
return nil, nil, errNotANote
}
f, _, err := extract.OpenRegular(r, base)
if err != nil {
return nil, nil, err
}
defer func() { _ = f.Close() }()
b, err := io.ReadAll(f)
if err != nil {
return nil, nil, err
}
return b, fi, nil
}
@@ -1,130 +0,0 @@
package extract
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
const extractPkg = "github.com/mathiasbq/hyperguild/ingestion/internal/extract"
// parseNonTest parses every non-test Go file under the module.
func parseNonTest(t *testing.T) map[string]*ast.File {
t.Helper()
fset := token.NewFileSet()
out := map[string]*ast.File{}
err := filepath.WalkDir(filepath.Join("..", ".."), func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && (d.Name() == "vendor" || d.Name() == ".git") {
return filepath.SkipDir
}
if d.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, p, nil, 0)
if perr != nil {
return perr
}
out[filepath.ToSlash(p)] = f
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
// PR #156 review: reading a caller-chosen path by name reopens it after the
// containment check (hyperguild#153 item 3). Outside this package, nothing
// may reference extract.Text — called OR taken as a value — under whatever
// name the package is imported (round 2: an alias and a func value slipped
// past a plain text search).
func TestNoPackageReferencesExtractTextOutsideExtract(t *testing.T) {
for path, f := range parseNonTest(t) {
if strings.Contains(path, "internal/extract/") {
continue
}
names := map[string]bool{}
for _, imp := range f.Imports {
if p, _ := strconv.Unquote(imp.Path.Value); p == extractPkg {
name := "extract"
if imp.Name != nil {
name = imp.Name.Name
}
if name == "." {
t.Errorf("%s dot-imports extract; import it by name so its use stays checkable", path)
}
names[name] = true
}
}
if len(names) == 0 {
continue
}
ast.Inspect(f, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok {
if id, ok := sel.X.(*ast.Ident); ok && names[id.Name] && sel.Sel.Name == "Text" {
t.Errorf("%s references %s.Text; use extract.TextIn through an os.Root", path, id.Name)
}
}
return true
})
}
}
// PR #156 review round 2: the FIFO fix (open non-blocking, check the handle)
// lives in OpenRegular, and reverting any caller to a plain Root.Open stayed
// green. In the three raw/ reader FILES parsed below (and only those: a
// wrapper in another file is not seen), no Open/OpenFile/ReadFile selector
// may appear outside OpenRegular, called or taken as a method value. This is
// a second layer; the behavioural race tests in internal/api and
// internal/watcher are what catch a rewrite through any API (round 3).
// osOpenAllowed names the functions in guarded files that may call os.Open*
// on a path that is not a raw/ read.
var osOpenAllowed = map[string]bool{
"internal/watcher/watcher.go:appendWatcherLog": true, // ingest log append, not a raw/ read
// Text reads by path; outside this package only TextIn's temp copy reaches
// it (TestNoPackageReferencesExtractTextOutsideExtract enforces that).
"internal/extract/extract.go:Text": true,
}
func TestRawReaderFilesUseNoOpenOrReadFileOutsideOpenRegular(t *testing.T) {
guarded := []string{"internal/api/rawroot.go", "internal/extract/extract.go", "internal/watcher/watcher.go"}
files := parseNonTest(t)
for _, g := range guarded {
var f *ast.File
for p, af := range files {
if strings.HasSuffix(p, g) {
f = af
}
}
if f == nil {
t.Fatalf("guarded file %s not found", g)
}
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Name.Name == "OpenRegular" {
continue
}
ast.Inspect(fn, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
if sel.Sel.Name == "Open" || sel.Sel.Name == "OpenFile" || sel.Sel.Name == "ReadFile" {
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "os" && osOpenAllowed[g+":"+fn.Name.Name] {
return true // a named non-raw write (the ingest log append)
}
t.Errorf("%s: %s calls .%s; raw/ reads must go through OpenRegular", g, fn.Name.Name, sel.Sel.Name)
}
return true
})
}
}
}
-69
View File
@@ -3,10 +3,8 @@ package extract
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"strings" "strings"
"syscall"
) )
// Text reads the file at path and returns its plain-text content. // Text reads the file at path and returns its plain-text content.
@@ -43,70 +41,3 @@ func fileExt(path string) string {
} }
return "" return ""
} }
// TextIn is Text for a file read through an os.Root (hyperguild#153): the
// read cannot leave the root, so a path checked earlier cannot be swapped
// for a symlink pointing elsewhere before it is read. Formats that need an
// external tool (pdf, docmark) are copied to a temp file from the handle
// opened through the root, then extracted from the copy.
func TextIn(r *os.Root, rel string) (string, error) {
fi, err := r.Lstat(rel)
if err != nil {
return "", fmt.Errorf("stat %s: %w", rel, err)
}
if !fi.Mode().IsRegular() {
return "", fmt.Errorf("%s: not a regular file", rel)
}
f, _, err := OpenRegular(r, rel)
if err != nil {
return "", fmt.Errorf("open %s: %w", rel, err)
}
defer func() { _ = f.Close() }()
ext := strings.ToLower(fileExt(rel))
switch ext {
case ".md", ".txt":
b, err := io.ReadAll(f)
if err != nil {
return "", fmt.Errorf("read %s: %w", rel, err)
}
return string(b), nil
case ".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
tmp, err := os.CreateTemp("", "ingest-*"+ext)
if err != nil {
return "", fmt.Errorf("temp copy: %w", err)
}
defer func() { _ = os.Remove(tmp.Name()) }()
if _, err := io.Copy(tmp, f); err != nil {
_ = tmp.Close()
return "", fmt.Errorf("copy %s: %w", rel, err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("copy %s: %w", rel, err)
}
return Text(tmp.Name())
default:
return "", fmt.Errorf("unsupported file extension: %s", ext)
}
}
// OpenRegular opens rel inside r and returns it only if the OPENED handle is
// a regular file. The open is non-blocking, so a FIFO swapped in after an
// Lstat (PR #156 review: it hung open(2) forever) is refused promptly rather
// than stalling the reader. Callers keep their Lstat check, which refuses
// symlinks outright; this closes the race between that check and the open.
func OpenRegular(r *os.Root, rel string) (*os.File, os.FileInfo, error) {
f, err := r.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, nil, err
}
fi, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, nil, err
}
if !fi.Mode().IsRegular() {
_ = f.Close()
return nil, nil, fmt.Errorf("%s: not a regular file", rel)
}
return f, fi, nil
}
-64
View File
@@ -1,64 +0,0 @@
package extract
import (
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#153 item 3: ingest checked a path, then extract.Text reopened it
// by name, so a file swapped for a symlink in between was followed out of
// raw/. TextIn reads through an os.Root, which refuses to leave the root.
func TestTextInReadsInsideTheRootAndRefusesEscapes(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "ok.md"), []byte("inside"), 0o644))
outside := filepath.Join(t.TempDir(), "secret.md")
require.NoError(t, os.WriteFile(outside, []byte("SENTINEL-153-TEXTIN"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(root, "leak.md")))
r, err := os.OpenRoot(root)
require.NoError(t, err)
defer func() { _ = r.Close() }()
got, err := TextIn(r, "ok.md")
require.NoError(t, err)
assert.Equal(t, "inside", got)
got, err = TextIn(r, "leak.md")
assert.Error(t, err)
assert.NotContains(t, got, "SENTINEL-153-TEXTIN")
_, err = TextIn(r, "../"+filepath.Base(outside))
assert.Error(t, err)
}
// PR #156 review: a regular file swapped for a FIFO between Lstat and Open
// blocked open(2) forever. OpenRegular opens non-blocking and checks the type
// on the opened handle, so a FIFO is refused promptly instead of hanging.
func TestOpenRegularRefusesAFIFOWithoutBlocking(t *testing.T) {
dir := t.TempDir()
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe.md"), 0o644))
r, err := os.OpenRoot(dir)
require.NoError(t, err)
defer func() { _ = r.Close() }()
done := make(chan error, 1)
go func() {
f, _, err := OpenRegular(r, "pipe.md")
if f != nil {
_ = f.Close()
}
done <- err
}()
select {
case err := <-done:
assert.Error(t, err)
case <-time.After(2 * time.Second):
t.Fatal("OpenRegular blocked on a FIFO")
}
}
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/capture" "github.com/mathiasbq/hyperguild/ingestion/internal/capture"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync" "github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
"github.com/mathiasbq/hyperguild/ingestion/internal/notes" "github.com/mathiasbq/hyperguild/ingestion/internal/notes"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
@@ -548,7 +549,7 @@ func (s *Server) brainIngest(ctx context.Context, args json.RawMessage) (json.Ra
return nil, err return nil, err
} }
a.Path = resolved a.Path = resolved
text, err := api.ReadIngestFile(s.brainDir, a.Path) text, err := extract.Text(a.Path)
if err != nil { if err != nil {
return nil, fmt.Errorf("extract: %w", err) return nil, fmt.Errorf("extract: %w", err)
} }
@@ -1,153 +0,0 @@
// Package symlinkguard is the hyperguild#157 tripwire. The brain directory is
// a Syncthing sendreceive folder and Syncthing syncs symlinks; several brain
// readers (search, vectorstore, context, graph) follow a symlink out of the
// brain and would index or return whatever it points to. Until every reader
// reads through an os.Root, a symlink inside the brain, or a directory the
// scan cannot read (which could hide one), is an incident: the server refuses
// to start on one (main), and Watch reports one that appears while it runs.
package symlinkguard
import (
"context"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// DefaultInterval is used when Watch is given a non-positive interval.
const DefaultInterval = 5 * time.Minute
// Notifier is the out-of-band alert sink (audit.Notifier satisfies it).
type Notifier interface {
Notify(ctx context.Context, msg string) error
}
// Result is what a scan found: symlinks, and directories it could not read
// (each of which could hide a symlink). Paths are brain-relative.
type Result struct {
Symlinks []string
Unreadable []string
}
// Clean reports whether the scan found nothing to act on.
func (r Result) Clean() bool { return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 }
// 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 := 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)))
}
if len(r.Unreadable) > 0 {
parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable)))
}
return strings.Join(parts, "; ")
}
// 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
// scanned like any other directory. brainDir itself may be a symlink (a
// volume mount); it is resolved first. The error is non-nil when the root
// cannot be resolved (wrapping os.ErrNotExist when it is missing) or cannot be
// listed.
func Scan(brainDir string) (Result, error) {
root, err := filepath.EvalSymlinks(brainDir)
if err != nil {
return Result{}, fmt.Errorf("resolve brain dir: %w", err)
}
var r Result
rootGit := filepath.Join(root, ".git")
walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
rel, _ := filepath.Rel(root, p)
rel = filepath.ToSlash(rel)
if err != nil {
if p == root {
return err
}
r.Unreadable = append(r.Unreadable, rel)
if d != nil && d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() && p == rootGit {
return filepath.SkipDir
}
if d.Type()&os.ModeSymlink != 0 {
r.Symlinks = append(r.Symlinks, rel)
}
return nil
})
sort.Strings(r.Symlinks)
sort.Strings(r.Unreadable)
if walkErr != nil {
// Only the root's own error reaches here (deeper errors are recorded
// as Unreadable). A root that cannot be listed cannot be shown clean
// (PR #159 round 2: discarding this reported clean while search
// served a planted symlink).
return r, fmt.Errorf("scan brain dir: %w", walkErr)
}
return r, nil
}
// Watch rescans every interval (DefaultInterval if non-positive) until ctx
// 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()
reported := ""
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
r, err := Scan(brainDir)
var key, msg string
switch {
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)
continue
}
reported = key
}
}
@@ -1,189 +0,0 @@
package symlinkguard
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#157: the brain dir is a Syncthing sendreceive folder and
// Syncthing syncs symlinks; several readers follow them out of the brain.
// Until every reader goes through an os.Root, a symlink anywhere in the brain,
// or a directory the scan cannot read (which could hide one), is an incident.
func TestScanReportsSymlinksAtEveryDepthIncludingNestedGit(t *testing.T) {
brain := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w", "facts", ".git"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(brain, ".git", "objects"), 0o755))
require.NoError(t, os.Symlink(filepath.Join(outside, "f"), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
require.NoError(t, os.Symlink(outside, filepath.Join(brain, "wiki", "dirlink")))
// PR #159 review: only the ROOT .git is skipped; a nested .git is not
// special to the brain readers, which walk into it.
require.NoError(t, os.Symlink(filepath.Join(outside, "g"), filepath.Join(brain, "wiki", "w", "facts", ".git", "leak.md")))
require.NoError(t, os.Symlink(outside, filepath.Join(brain, ".git", "objects", "alt")))
r, err := Scan(brain)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"wiki/w/facts/leak.md", "wiki/dirlink", "wiki/w/facts/.git/leak.md"}, r.Symlinks)
assert.Empty(t, r.Unreadable)
}
// PR #159 review: WalkDir stopped at the first unreadable directory and the
// startup check passed, while search (which keeps walking) served a planted
// symlink. Scan now records the unreadable dir and keeps going.
func TestScanReportsUnreadableDirsAndKeepsWalkingPastThem(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads mode-000 directories")
}
brain := t.TempDir()
locked := filepath.Join(brain, "aaa")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "zzz-planted")))
r, err := Scan(brain)
require.NoError(t, err)
assert.Equal(t, []string{"aaa"}, r.Unreadable)
assert.Equal(t, []string{"zzz-planted"}, r.Symlinks, "the walk continues past the unreadable dir")
}
func TestScanResolvesASymlinkedRootAndReportsSymlinkedEntriesInsideIt(t *testing.T) {
real := t.TempDir()
link := filepath.Join(t.TempDir(), "brain")
require.NoError(t, os.Symlink(real, link))
require.NoError(t, os.WriteFile(filepath.Join(real, "n.md"), []byte("x"), 0o644))
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(real, "inner")))
r, err := Scan(link)
require.NoError(t, err)
assert.Equal(t, []string{"inner"}, r.Symlinks)
}
func TestScanOfAMissingRootIsErrNotExist(t *testing.T) {
_, err := Scan(filepath.Join(t.TempDir(), "nope"))
assert.True(t, errors.Is(err, os.ErrNotExist), "got %v", err)
}
type recNotifier struct {
mu sync.Mutex
msgs []string
fail int // fail the first N calls
}
func (r *recNotifier) Notify(_ context.Context, m string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.msgs = append(r.msgs, m)
if r.fail > 0 {
r.fail--
return errors.New("ntfy down")
}
return nil
}
func (r *recNotifier) count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.msgs) }
func TestWatchNotifiesOncePerDistinctSetAndAgainForANewSet(t *testing.T) {
brain := t.TempDir()
n := &recNotifier{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, n)
time.Sleep(50 * time.Millisecond)
assert.Zero(t, n.count(), "a clean brain raises nothing")
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "first")))
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
time.Sleep(60 * time.Millisecond)
assert.Equal(t, 1, n.count(), "the same set is reported once, not every tick")
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "second")))
require.Eventually(t, func() bool { return n.count() == 2 }, 2*time.Second, 10*time.Millisecond)
}
// PR #159 review: last was recorded before Notify, so an alert lost to an
// ntfy outage was never retried while the symlink stayed.
func TestWatchRetriesAnAlertThatFailedToSend(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "planted")))
n := &recNotifier{fail: 2}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, n)
require.Eventually(t, func() bool { return n.count() == 3 }, 2*time.Second, 10*time.Millisecond)
time.Sleep(60 * time.Millisecond)
assert.Equal(t, 3, n.count(), "after one successful send, no more")
}
func TestWatchAlertsOnAnUnreadableDir(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads mode-000 directories")
}
brain := t.TempDir()
locked := filepath.Join(brain, "locked")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
n := &recNotifier{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, n)
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
assert.Contains(t, n.msgs[0], "locked")
}
// PR #159 review: a non-positive interval panicked time.NewTicker inside the
// goroutine and crashlooped the server.
func TestWatchToleratesANonPositiveInterval(t *testing.T) {
brain := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { Watch(ctx, brain, 0, nil); close(done) }()
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Watch did not return on cancel")
}
}
// PR #159 round 2: a brain root that can be entered but not listed (mode
// 0100) made WalkDir fail at the root; the error was discarded and Scan
// reported clean, while search (which goes straight into wiki/) still served
// a planted symlink. A root that cannot be listed is now an error.
func TestScanOfAnUnlistableRootIsAnError(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root lists mode-0100 directories")
}
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "wiki", "leak.md")))
require.NoError(t, os.Chmod(brain, 0o100))
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
_, err := Scan(brain)
require.Error(t, err)
assert.False(t, errors.Is(err, os.ErrNotExist), "an unlistable root is not a missing root")
}
// The same set that disappears and comes back is reported again.
func TestWatchReportsASetAgainAfterItWasCleared(t *testing.T) {
brain := t.TempDir()
link := filepath.Join(brain, "planted")
n := &recNotifier{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, n)
require.NoError(t, os.Symlink(t.TempDir(), link))
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
require.NoError(t, os.Remove(link))
time.Sleep(60 * time.Millisecond)
require.NoError(t, os.Symlink(t.TempDir(), link))
require.Eventually(t, func() bool { return n.count() == 2 }, 2*time.Second, 10*time.Millisecond)
}
+26 -58
View File
@@ -48,25 +48,8 @@ func Start(ctx context.Context, cfg Config) {
func processDir(ctx context.Context, cfg Config, date string) []error { func processDir(ctx context.Context, cfg Config, date string) []error {
rawDir := filepath.Join(cfg.BrainDir, "raw") rawDir := filepath.Join(cfg.BrainDir, "raw")
// Every read goes through an os.Root on raw/ (hyperguild#153): raw/ is
// writable via Syncthing, and a file swapped for a symlink between the walk
// and the read must not lead out of it. A symlinked raw/ is refused.
if fi, lerr := os.Lstat(rawDir); lerr != nil {
if os.IsNotExist(lerr) {
return nil
}
return []error{fmt.Errorf("stat raw dir: %w", lerr)}
} else if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
return []error{fmt.Errorf("raw dir %s is not a real directory; refusing to ingest from it", rawDir)}
}
root, err := os.OpenRoot(rawDir)
if err != nil {
return []error{fmt.Errorf("open raw root: %w", err)}
}
defer func() { _ = root.Close() }()
var errs []error var errs []error
err = filepath.WalkDir(rawDir, func(path string, d os.DirEntry, err error) error { err := filepath.WalkDir(rawDir, func(path string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
@@ -103,10 +86,6 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
if strings.HasPrefix(d.Name(), "tunnel-candidates-") { if strings.HasPrefix(d.Name(), "tunnel-candidates-") {
return nil return nil
} }
// Only regular files are notes; a symlink's target may be anywhere.
if !d.Type().IsRegular() {
return nil
}
// Skip files that have already been processed or permanently failed. // Skip files that have already been processed or permanently failed.
if _, err := os.Stat(path + ".processed"); err == nil { if _, err := os.Stat(path + ".processed"); err == nil {
@@ -116,7 +95,7 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
return nil return nil
} }
if err := processFile(ctx, cfg, root, rawDir, path, date); err != nil { if err := processFile(ctx, cfg, path, date); err != nil {
errs = append(errs, fmt.Errorf("process %s: %w", filepath.Base(path), err)) errs = append(errs, fmt.Errorf("process %s: %w", filepath.Base(path), err))
} }
return nil return nil
@@ -130,15 +109,11 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
// processFile reads a file, calls pipeline.Run, copies it to processed/ or failed/, // processFile reads a file, calls pipeline.Run, copies it to processed/ or failed/,
// and writes a marker file next to the original so the watcher skips it next poll. // and writes a marker file next to the original so the watcher skips it next poll.
// The original file is never deleted, keeping Syncthing-connected vaults (e.g. Obsidian) intact. // The original file is never deleted, keeping Syncthing-connected vaults (e.g. Obsidian) intact.
func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, date string) error { func processFile(ctx context.Context, cfg Config, path, date string) error {
filename := filepath.Base(path) filename := filepath.Base(path)
source := deriveSource(filename) source := deriveSource(filename)
rel, err := filepath.Rel(rawDir, path)
if err != nil {
return fmt.Errorf("relative path: %w", err)
}
content, err := extract.TextIn(root, rel) content, err := extract.Text(path)
if err != nil { if err != nil {
return fmt.Errorf("extract text: %w", err) return fmt.Errorf("extract text: %w", err)
} }
@@ -151,7 +126,7 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
return fmt.Errorf("mkdir failed dir: %w", mkErr) return fmt.Errorf("mkdir failed dir: %w", mkErr)
} }
dest := filepath.Join(failedDir, filename) dest := filepath.Join(failedDir, filename)
if cpErr := copyFromRoot(root, rel, dest); cpErr != nil { if cpErr := copyFile(path, dest); cpErr != nil {
return fmt.Errorf("copy to failed: %w", cpErr) return fmt.Errorf("copy to failed: %w", cpErr)
} }
if mkErr := os.WriteFile(path+".failed", []byte(runErr.Error()), 0o644); mkErr != nil { if mkErr := os.WriteFile(path+".failed", []byte(runErr.Error()), 0o644); mkErr != nil {
@@ -179,7 +154,7 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
base := strings.TrimSuffix(filename, ext) base := strings.TrimSuffix(filename, ext)
dest = filepath.Join(processedDir, base+"-"+time.Now().UTC().Format("150405")+ext) dest = filepath.Join(processedDir, base+"-"+time.Now().UTC().Format("150405")+ext)
} }
if err := copyFromRoot(root, rel, dest); err != nil { if err := copyFile(path, dest); err != nil {
return fmt.Errorf("copy to processed: %w", err) return fmt.Errorf("copy to processed: %w", err)
} }
if err := os.WriteFile(path+".processed", []byte(date), 0o644); err != nil { if err := os.WriteFile(path+".processed", []byte(date), 0o644); err != nil {
@@ -190,6 +165,26 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
return nil return nil
} }
// copyFile copies src to dst, creating dst if it doesn't exist.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open src: %w", err)
}
defer in.Close() //nolint:errcheck
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create dst: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
out.Close() //nolint:errcheck
return fmt.Errorf("copy: %w", err)
}
return out.Close()
}
// deriveSource turns a filename into a human-readable source name. // deriveSource turns a filename into a human-readable source name.
// "shape-up-book.md" → "Shape Up Book" // "shape-up-book.md" → "Shape Up Book"
func deriveSource(filename string) string { func deriveSource(filename string) string {
@@ -226,30 +221,3 @@ func appendWatcherLog(brainDir, filename string, runErr error, date string) erro
} }
return f.Close() return f.Close()
} }
// copyFromRoot archives a raw/ file read through the root, refusing anything
// that is not a regular file, so a swapped-in symlink cannot copy an outside
// file into raw/processed/ or raw/failed/ (hyperguild#153).
func copyFromRoot(root *os.Root, rel, dst string) error {
fi, err := root.Lstat(rel)
if err != nil {
return err
}
if !fi.Mode().IsRegular() {
return fmt.Errorf("%s: not a regular file", rel)
}
in, _, err := extract.OpenRegular(root, rel)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
-102
View File
@@ -7,9 +7,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing" "testing"
"time" "time"
@@ -285,102 +282,3 @@ func TestProcessDir_SkipsTunnelCandidateFiles(t *testing.T) {
_, err = os.Stat(filepath.Join(brainDir, "raw", "processed", date, "tunnel-candidates-2026-07-19.md")) _, err = os.Stat(filepath.Join(brainDir, "raw", "processed", date, "tunnel-candidates-2026-07-19.md"))
assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not be copied to processed/") assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not be copied to processed/")
} }
// hyperguild#153: raw/ is writable via Syncthing, and the watcher ingests
// whatever lands there with no API call at all. WalkDir yields symlinked
// files, and extract.Text followed them, so a symlink to any pod-readable
// file would be extracted into the brain. The watcher now ingests only
// regular files, read through an os.Root on raw/.
func TestProcessDir_IngestsTheRealNoteAndSkipsTheSymlinkBesideIt(t *testing.T) {
brainDir := setupBrainDir(t)
outside := filepath.Join(t.TempDir(), "secret.md")
require.NoError(t, os.WriteFile(outside, []byte("SENTINEL-153-WATCHER"), 0o644))
// "a-" sorts before "real": a walk that stopped at the first symlink
// (filepath.SkipAll — one synced link halting all ingestion) would fail
// the call count below (PR #156 review).
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "a-leak.md")))
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "real.md"), []byte("real note"), 0o644))
var sawSentinel bool
calls := 0
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
Complete: func(_ context.Context, _, user string) (string, error) {
calls++
if strings.Contains(user, "SENTINEL-153-WATCHER") {
sawSentinel = true
}
b, _ := json.Marshal([]pipeline.RawPage{{Title: "X", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}})
return string(b), nil
},
Schema: "# Schema",
}}
errs := processDir(context.Background(), cfg, time.Now().UTC().Format("2006-01-02"))
assert.Empty(t, errs)
assert.False(t, sawSentinel, "the symlink target must never reach the pipeline")
assert.Equal(t, 1, calls, "exactly the real note is ingested; the symlink is not a note")
}
// A symlinked raw/ is refused outright: os.OpenRoot would otherwise follow
// it and confine reads to wherever it points.
func TestProcessDir_RefusesASymlinkedRawDir(t *testing.T) {
brainDir := t.TempDir()
elsewhere := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(elsewhere, "x.md"), []byte("SENTINEL-153-RAWLINK"), 0o644))
require.NoError(t, os.Symlink(elsewhere, filepath.Join(brainDir, "raw")))
calls := 0
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
Complete: func(context.Context, string, string) (string, error) { calls++; return "[]", nil },
Schema: "# Schema",
}}
errs := processDir(context.Background(), cfg, time.Now().UTC().Format("2006-01-02"))
assert.NotEmpty(t, errs, "a symlinked raw/ must be reported, not silently ingested")
assert.Zero(t, calls)
}
// PR #156: a FIFO swapped into raw/ between the watcher's checks and its
// read or archive copy must not hang the watcher, which runs unattended.
func TestProcessDir_DoesNotHangUnderAFIFOSwapRace(t *testing.T) {
brainDir := setupBrainDir(t)
raw := filepath.Join(brainDir, "raw")
var stop atomic.Bool
go func() {
for i := 0; !stop.Load(); i++ {
tmp := filepath.Join(raw, ".swap")
_ = os.Remove(tmp)
if i%2 == 0 {
_ = os.WriteFile(tmp, []byte("x"), 0o644)
} else {
_ = syscall.Mkfifo(tmp, 0o644)
}
_ = os.Rename(tmp, filepath.Join(raw, "r.md"))
}
}()
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
Complete: func(context.Context, string, string) (string, error) {
b, _ := json.Marshal([]pipeline.RawPage{{Title: "R", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}})
return string(b), nil
},
Schema: "# Schema",
}}
var iters atomic.Int64
done := make(chan struct{})
go func() {
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
_ = os.Remove(filepath.Join(raw, "r.md.processed"))
_ = os.Remove(filepath.Join(raw, "r.md.failed"))
_ = processDir(context.Background(), cfg, "2026-10-03")
iters.Add(1)
}
close(done)
}()
select {
case <-done:
stop.Store(true)
case <-time.After(8 * time.Second):
stop.Store(true)
if f, err := os.OpenFile(filepath.Join(raw, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
_ = f.Close()
}
t.Fatalf("the watcher hung on a swapped-in FIFO after %d iterations", iters.Load())
}
}