Compare commits

...
Author SHA1 Message Date
mathiasandClaude Sonnet 5.5 80f3fd3c5e fix(wing,server): client names are matched as whole Unicode words; unsafe lists are a startup error (review of #214)
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
- WrapNames bounds the bare name list with Unicode-aware edges instead of \b
  (Go's \b is ASCII-only, so a name with a non-ASCII edge letter such as Åke,
  Société, Ørsted would never have matched), and validates the list on its own:
  it must compile, must not match the empty string, and cannot break out of its
  group ('x)|.*(' compiles alone only if balanced)
- NewRouter refuses any pattern that matches text naming no client
- docs state the whole-word semantics and what is not matched
Mutation-checked. Open decision for Mathias: whole-word versus substring recall
for a confidentiality filter (plurals, CamelCase and digits glued on do not match).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:44:23 +02:00
mathiasandClaude Sonnet 5.5 0582dfdf6b fix(server): wrap the shared client-name alternation like claudewatcher does (#209)
CI / Lint / Test / Vet (pull_request) Successful in 1m6s
CI / Mirror to GitHub (pull_request) Skipped
The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as a bare alternation of client
names; claudewatcher compiles it as (?i)\b(...)\b. The raw/ router used it raw, so
a short client name would match inside ordinary words and park clips that have
nothing to do with the client. INGEST_CLIENT_BLOCK stays a full regexp, used as
is. Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:39:37 +02:00
mathias 4c2276b46b Merge pull request 'feat(ingestion): clips land in web-clip by rule; shared concepts/entities; approve parked notes; remove the classifier (#208, #209)' (#211) from wt/web-clip into main
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 1m6s
2026-10-20 19:36:51 +00:00
mathiasandClaude Sonnet 5.5 90752f2ac9 fix(pipeline,api): write the source page last; say how to clear an unreadable note (review of #211, pass 4)
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
The source page carries source_hash, which the drift check trusts. Written first,
a batch that failed midway left a hash behind, so re-ingesting the same content
was skipped and the rest of the batch was lost for good. In a wing it is now
written last. An over-cap note in brain_pending says promote/reject cannot act
on it and to delete or move it by hand. Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:00:18 +02:00
mathiasandClaude Sonnet 5.5 27b1579b80 fix(pipeline,api,wing): never archive a note as processed when nothing was ingested; titles do not merge sources; ties prefer the page on disk (review of #211, pass 3)
CI / Lint / Test / Vet (pull_request) Successful in 1m6s
CI / Mirror to GitHub (pull_request) Skipped
- in a wing, an ingest that writes no source page is an error: the note goes to
  raw/failed with the warnings in log.md instead of leaving the inbox as
  'processed' (model returned [], only invalid types, only clashing entities)
- the wing's source path is deterministic, so Resolve no longer redirects a
  source onto another note's page because the model gave both the same title
  ('Home'); that overwrote the hash and the text
- a batch tie between concept and entity prefers the page already on disk, so
  its update is not lost; the wing's own source page keeps its slug even when a
  foreign page shares it
- brain_pending lists an unreadable (over-cap) note with the reason instead of
  dropping it; the client filter's 4 KiB match-span limit is documented
Each has a test that fails without it (mutation-checked).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:56:03 +02:00
mathiasandClaude Sonnet 5.5 49286ba8b8 fix(api,watcher,server): reject moves non-markdown untouched; read caps; BOM; strict routing flag (review of #211, pass 2)
CI / Lint / Test / Vet (pull_request) Successful in 1m2s
CI / Mirror to GitHub (pull_request) Skipped
- brain_reject on a parked pdf/txt moves the bytes; it no longer prepends YAML
- raw/ notes are read through a cap (readCapped); an endless reader test fails
  instead of exhausting memory
- a byte-order mark no longer hides a note's wing: key
- INGEST_WING_ROUTING is parsed strictly; a typo stops the server rather than
  leaving routing silently off
- tests for the reserved-wing check in every disguise (Sources, sources., ...)
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:49:10 +02:00
mathiasandClaude Sonnet 5.5 6f5fb481ad fix(wing): client filter handles multi-word/\s/\b patterns, homoglyphs, marks, fillers; checked in bounded chunks (review of #211, pass 2)
Views of each chunk: as is; NFKD + marks/format/fillers dropped + Cyrillic/Greek
lookalikes mapped + dashes normalised, with whitespace collapsed (acme corp,
\s) or removed (acme-\nclient); and a soft hyphen read as a dash. Chunks of 1 MiB
with a 4 KiB overlap bound the folding's allocation (it was ~14x the input: 919
MiB for a 64 MiB note). Documented limits: leetspeak, punctuation splits,
encodings and homoglyphs outside a small table. Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:45:00 +02:00
mathiasandClaude Sonnet 5.5 74d6a08c7b fix(pipeline): slug clashes inside one batch; the source page is never the one skipped (review of #211, pass 2)
dropSlugClashes only compared against what was on disk, so a clip named like an
entity the model extracted wrote both pages and neither could be updated again.
In-batch winners: source over entity over concept. Against the brain, only
entities/concepts are skipped; the source's slug was already chosen free or its
own by resolveSourceSlug, so an existing bad pair no longer locks the source out.
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:43:39 +02:00
mathiasandClaude Sonnet 5.5 87c40fe364 fix(api,watcher): name the queue, list parked non-markdown, reserved wings, top-level-only frontmatter key; document the inbox (review of #211)
CI / Lint / Test / Vet (pull_request) Successful in 1m2s
CI / Mirror to GitHub (pull_request) Skipped
- brain_promote/brain_reject take an optional queue (inbox | needs-review); a
  name present in both is refused instead of silently acting on the top-level
  note; brain_pending labels every entry with its queue
- parked pdf/txt files are listed (they were invisible); only markdown can be
  promoted, the others are moved or rejected
- promote refuses sources/concepts/entities/wiki as a wing
- frontmatterWing reads only a top-level key inside a properly fenced block
- docs/raw-inbox.md: the contract, the three env vars, the known limits
Each behaviour has a test that fails without it (mutation-checked).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:34:52 +02:00
mathiasandClaude Sonnet 5.5 6b6d430383 fix(wing): the client filter survives cheap evasions; lookalike and reserved folder names are refused (review of #211)
- client pattern is matched against the text and against an NFKC-folded,
  format-character-free, dash-normalised, whitespace-free form
  (non-breaking hyphen, zero-width characters, soft hyphen, line splits,
  fullwidth letters). x/text/unicode/norm was already in the module graph as an
  indirect dependency; this makes it direct, no new download.
- folder/frontmatter wing names must be ASCII (strings.ToLower folds the Kelvin
  sign to 'k') and must not be sources/concepts/entities/wiki; such names are
  never proposed as a wing, and a reserved default is a startup error
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:29:09 +02:00
mathiasandClaude Sonnet 5.5 ae02d4764c fix(pipeline): same-named sources in two wings, entity/source slug clashes, model-chosen types (review of #211)
- a source whose slug is taken by anything but this wing's own page gets a
  wing-qualified, stable slug (talk-homelab) instead of failing the ingest
- an extracted entity/concept whose slug belongs to a different page is skipped
  with a warning, so one page can no longer lock a source out of re-ingest
- in a wing only source/concept/entity are valid page types; the model (and so
  the clip) can no longer choose wiki/<type>/ directories
Mutation-checked; stable-slug choice pinned directly because title resolution
masks it at the Run level.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:27:28 +02:00
mathiasandClaude Sonnet 5.5 8e488b6e4b fix(pipeline): a slug used in two places is refused even when one is the wing's own (#208)
CI / Lint / Test / Vet (pull_request) Successful in 1m8s
CI / Mirror to GitHub (pull_request) Skipped
FindSlug returned the first hit, so a wing's own page of a name masked another
page of the same name (the retired dirs are searched before the wings, and wings
alphabetically). FindSlugs returns every match and callers refuse any that is
not their own. Found by the legacy-move planner's own test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:22:20 +02:00
mathias 3ccf6b46ac Merge remote-tracking branch 'origin/main' into wt/web-clip
CI / Mirror to GitHub (pull_request) Skipped
CI / Lint / Test / Vet (pull_request) Successful in 1m13s
2026-10-20 20:19:45 +02:00
mathias 44207f8ccf Merge pull request 'fix(watcher): writes through the brain root (lands #195 on current main); size cap; long-name case (#158, #198, #199)' (#210) from wt/watcher-write-safety into main
CI / Lint / Test / Vet (push) Successful in 1m1s
CI / Mirror to GitHub (push) Successful in 4s
2026-10-20 18:19:17 +00:00
mathias 603d2f9dfb Merge branch 'wt/watcher-write-safety' into wt/web-clip 2026-10-20 20:18:39 +02:00
mathiasandClaude Sonnet 5.5 8988c291ca feat(server): INGEST_WING_ROUTING wires the Router; remove the classifier path (#208)
Removes the LLM classifier, the substring 'BM25' scorer, the agreement gate,
the backtest and the old migrate/wingmigrate tooling: measured at 2 of 55
pages placed (#208), and the migration was built for the per-wing facts layout
this branch replaces. wing now holds the decision types, the Router and
KnownWings. INGEST_WING_ROUTING=1 builds the Router (default wing
INGEST_DEFAULT_WING=web-clip, client pattern from INGEST_CLIENT_BLOCK or
CLAUDE_INGEST_CLIENT_BLOCK); a bad default or pattern is fatal at startup.
Watcher tests rewritten for the Router, including a deliberate subfolder into
any existing wing, typo'd folders parked, forged-key and edit-while-placing
cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:18:39 +02:00
mathiasandClaude Sonnet 5.5 6717221989 fix(watcher): a held note is reported once; future-dated and both stale markers handled (review of #210, pass 2)
CI / Lint / Test / Vet (pull_request) Successful in 1m4s
CI / Mirror to GitHub (pull_request) Skipped
- a marker that blocks a note is warned about once, not silently forever
- a marker dated more than 10 minutes ahead (clock skew, a peer) is not
  believed and is treated as stale
- every stale marker is removed, not only the last; removal errors are logged
Each has a test that fails without it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:17:10 +02:00
mathiasandClaude Sonnet 5.5 409b85096b feat(api): brain_promote and brain_reject act on notes parked in raw/needs-review/ (#208)
brain_pending lists a parked note under its bare filename, but promote/reject
only read raw/<name>, so the documented approval path did not exist. They now
fall back to raw/needs-review/ (top level wins on a name clash; a symlinked
directory is refused) and promote drops the resolver's proposal keys.
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:14:47 +02:00
mathiasandClaude Sonnet 5.5 1025d77795 fix(watcher): unique archive copies, read caps at the read, stale markers (review of #210)
CI / Lint / Test / Vet (pull_request) Successful in 1m5s
CI / Mirror to GitHub (pull_request) Skipped
From the independent review of #210:
- F1: processed/ and failed/ copies are created O_EXCL with -2,-3 suffixes via
  createUnique; same-named notes from different folders no longer replace one
  another's only surviving copy
- F2: extract.TextInMax / readAllMax cap the bytes actually read (a note can
  grow after the Lstat); extract.TextIn gets a 64 MiB default cap
- F3: a regular marker older than its note describes an earlier note of the
  same name; it no longer hides the new one (warn + remove); non-regular
  markers still count as done (no writes through links)
- F4: the planted-marker-link test is renamed to what it observes
Each has a test that fails without it; the read-cap test uses an endless reader
that errors at 8 MiB so a regression fails instead of exhausting memory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:13:18 +02:00
mathiasandClaude Sonnet 5.5 750dc93bdb feat(watcher): Placer interface; acceptance tests for the sprint goal (#209)
A clip dropped in raw/ with no other action lands in web-clip, its entity and
concept are the shared pages, raw/ is empty and nothing waits for a human;
client-marked text and a typo'd wing name park; a deliberate subfolder places.
Real pipeline and file layout, model stubbed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:09:19 +02:00
mathiasandClaude Sonnet 5.5 ce5a5268e7 feat(pipeline): per-wing sources, shared concepts/entities, one slug one page (#208)
With Config.Wing a source goes to wiki/<wing>/sources/ (wing/hall frontmatter);
concepts and entities stay in the shared wiki/concepts and wiki/entities, so an
entity cited from two wings is ONE page with both sources (the per-wing facts/
layout duplicated it). A source slug already used anywhere but its own wing's
sources/ is refused (slug is the graph identity). wiki.FindSlug added.
Mutation-checked: collision guard, shared inventory, per-wing source path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:08:30 +02:00
mathiasandClaude Sonnet 5.5 8342de5c49 feat(wing): Router — rules-only placement: deliberate subfolder, else default wing (#208)
No model. A subfolder/frontmatter wing must be a plain name of an existing wing
and then places; conflicting signals and client-marker text park; everything
else goes to the default wing. Wings are read from disk per decision.
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:05:29 +02:00
mathiasandClaude Sonnet 5.5 d8c7defc78 fix(watcher): refuse oversize notes before reading them; pin the long-name marker case (#198, #199)
CI / Lint / Test / Vet (pull_request) Successful in 1m7s
CI / Mirror to GitHub (pull_request) Skipped
MaxNoteBytes (default 64 MiB) is checked on the Lstat size before extraction;
an oversize note stays in raw/, is reported once and not retried until it
changes. The #199 case (marker name over NAME_MAX) is covered by the
remember-on-any-failure retire path and now has a test that fails without it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:04:02 +02:00
mathiasandClaude Sonnet 5.5 2cc4f1b30d merge: land #195 (watcher/pipeline writes through the brain root, #158 part B) on top of inbox zero and the wing resolver
Reconciled with v0.25.0 semantics:
- markers are only the fallback when the original cannot be removed, and are
  now written through the brain root; the refused set (size+mtime keyed) replaces
  the path-only 'stuck' set
- #195's tests updated for inbox zero (archive written, original retired, no
  marker), and the planted-marker-link test keeps its point: nothing is ever
  written through the link, and the note is processed once
- the 'cannot retire' test now counts archive copies; it counted LLM calls,
  which pipeline.Run's source-hash check hides (mutation: removing the
  remember step left it green)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:02:15 +02:00
mathias 858ade6504 Merge pull request 'feat(ingestion): place raw notes in a wing without human typing (#205 S1–S4, S6)' (#207) from wt/wing-layout into main
CI / Lint / Test / Vet (push) Successful in 1m15s
CI / Mirror to GitHub (push) Successful in 5s
2026-10-20 06:21:44 +00:00
mathiasandClaude Sonnet 5.5 bd33a0a4e8 fix(watcher): park through the brain root, keep a note edited mid-resolve, honour only real fences (#205)
CI / Lint / Test / Vet (pull_request) Successful in 1m7s
CI / Mirror to GitHub (pull_request) Skipped
From the second independent review of #207:
- parkForReview now skips the delete when the note changed since it was read
  (an LLM call sits in that window; Syncthing can deliver a newer version)
- parked copies are created via brainfs (real dirs only, O_EXCL, no symlink
  following), so a planted raw/needs-review symlink cannot redirect or hide a
  note; this also removes the need for the guard allowlist entry added in
  1e14c56, which is reverted
- a '----' line no longer closes the proposal block
Each has a test that failed first and a mutant that fails it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:21:25 +02:00
mathiasandClaude Sonnet 5.5 1e14c56a49 test(extract): allow reserveParkedName in the raw-reader guard (creates in needs-review/, reads nothing) (#205)
CI / Lint / Test / Vet (pull_request) Successful in 1m1s
CI / Mirror to GitHub (pull_request) Skipped
The previous commit was pushed with this guard red: the gate was run with ';'
instead of '&&'. Allowlisting is the guard's own sanctioned mechanism and is
called out for review.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:17:30 +02:00
mathiasandClaude Sonnet 5.5 006a891423 fix(watcher): never overwrite a parked note; a proposal cannot be forged or pre-empted; CRLF notes; wing dirs must be plain (#205)
CI / Lint / Test / Vet (pull_request) Failing after 1m2s
CI / Mirror to GitHub (pull_request) Skipped
From the independent review of #207:
- parked notes are created O_EXCL with -2,-3 suffixes (same-named notes from
  different subfolders were overwritten and their originals deleted)
- proposed_wing is emitted only if a plain wing name; a note's own proposal_*
  keys are dropped so they cannot outrank the resolver's (first-wins reads)
- CRLF notes: wing: key honoured, one valid frontmatter block on parking
- KnownWings skips wiki dirs that are not canonical lower-case names
- adds the wingmigrate dry-run/apply command
Each fix has a test that failed first and a mutant that fails it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:16:48 +02:00
mathiasandClaude Sonnet 5.5 dd4a825f0a feat(migrate): plan and apply the move out of the retired wiki layout (#205 S5)
Sources are placed by the wing Resolver; concepts/entities inherit the strict
majority wing of the sources that cite them. Collisions, junk (auto-tunnel
logs) and anything unplaceable are Exceptions left in place. Apply writes the
destination O_EXCL then removes the source, only for Auto items.
Mutation-checked (junk guard, majority, slug identity, status gate,
no-overwrite, destination-exists).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:14:52 +02:00
mathiasandClaude Sonnet 5.5 b4b36c9bfd test(watcher): a re-synced unchanged note is not extracted twice; a changed one is (#205 S6)
CI / Lint / Test / Vet (pull_request) Successful in 1m4s
CI / Mirror to GitHub (pull_request) Skipped
Mutation-checked against the source-hash drift check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:11:26 +02:00
mathiasandClaude Sonnet 5.5 96c63906c2 test(watcher): make the frontmatter-forgery test reach the code it names (#205 S4)
The first version fed the text through a path that never carried it into the
file, so it passed under the mutant. A classifier error does carry it; the
test now fails with %q replaced by %s.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:10:54 +02:00
mathiasandClaude Sonnet 5.5 b9657973ff feat(api): brain_pending lists notes parked in raw/needs-review/ with the wing proposal (#205 S4)
Approval is moving the note into raw/<wing>/ (the watcher ingests it there);
a dedicated approve tool is tracked separately. Proposal reason is written
Go-quoted so LLM text cannot start a frontmatter key; pinned by a
mutation-checked test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:10:39 +02:00
mathiasandClaude Sonnet 5.5 20d874a23d feat(server): INGEST_WING_RESOLVE=1 wires the wing resolver into the watcher (#205 S3)
Off by default (retired layout unchanged). On: eligibility from
classification.yaml, local ingest LLM as classifier, BM25 as cross-check,
INGEST_WING_THRESHOLD (default 0.9, rejected outside [0,1]). Build failure
is fatal when explicitly enabled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:09:17 +02:00
mathiasandClaude Sonnet 5.5 2c6220782d feat(watcher): resolve each note's wing; park the unplaceable in raw/needs-review/ (#205 S3)
With Config.Resolver set, an explicit subfolder/frontmatter wing or an agreeing
classifier+scorer routes the note into that wing; anything else is parked with
its proposal in frontmatter and never extracted. needs-review/ is skipped by
the walk like rejected/. Nil Resolver keeps the retired layout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:08:15 +02:00
mathiasandClaude Sonnet 5.5 ad793f320d feat(pipeline): write into wiki/<wing>/{sources,facts}/ when Config.Wing is set (#205 S2)
Sources -> wiki/<wing>/sources, concepts/entities -> wiki/<wing>/facts with
kind: frontmatter; inventory, title resolution and back-references are
confined to the target wing. Empty Wing keeps the retired layout unchanged.
Mutation-checked: widening the inventory beyond the wing fails the
cross-wing back-reference test; a non-plain wing name is rejected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:07:14 +02:00
mathiasandClaude Sonnet 5.5 62de8ac2e6 feat(wing): score a wing by its best page, not summed hits; add wingbacktest command (#205 S1)
A first backtest showed BM25 summed over a wing's top hits favours big wings
(0/4 agreement). Best-page scoring is volume-robust; pinned by a test that
failed before the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:04:45 +02:00
mathiasandClaude Sonnet 5.5 365130459f feat(wing): backtest metric (coverage/precision at a threshold) and shared TopWing (#205 S1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:58:55 +02:00
mathiasandClaude Sonnet 5.5 c06509afe4 feat(wing): BM25 scorer as the independent cross-check (#205 S1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:58:14 +02:00
mathiasandClaude Sonnet 5.5 85d8ecb5c7 feat(wing): LLM classifier with tolerant, fail-closed reply parsing (#205 S1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:57:51 +02:00
mathiasandClaude Sonnet 5.5 5bbf47064d feat(wing): resolver that places a raw note in a wing without human typing (#205 S1)
Explicit subfolder/frontmatter wins; otherwise a classifier and an
independent scorer must agree above a threshold on a known, eligible wing.
Anything else is an Exception for a human. Never creates a wing, never
assigns an ineligible (client/untagged) wing. Mutation-checked: dropping the
eligibility, known-wing, scorer, threshold or conflict check fails a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:56:55 +02:00
mathias 2212ceedda Merge pull request 'feat(watcher): inbox zero — archived raw/ originals are removed, not copied' (#206) from wt/inbox-zero into main
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
2026-10-19 19:22:58 +00:00
mathiasandClaude Sonnet 5.5 e6e14102ee docs(watcher): state what changedSince can and cannot detect
CI / Lint / Test / Vet (pull_request) Successful in 1m4s
CI / Mirror to GitHub (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 21:09:13 +02:00
mathiasandClaude Sonnet 5.5 97ec9e7ce8 fix(watcher): keep a note that changed while it was being ingested
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
The archive holds the content read at the start; if Syncthing replaced the
note during the (minutes-long) LLM pass, removing the original would drop the
newer content. Compare size+mtime before removing; leave it for the next poll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 21:06:46 +02:00
mathiasandClaude Sonnet 5.5 87ae782527 fix(watcher): don't re-ingest a note that cannot be retired; pin archive-failure keeps original
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
When both Remove and the marker write fail (read-only raw/), remember the
path in memory so later polls skip it instead of writing another archive
copy every tick. Adds a test that a failed archive copy leaves the original
in raw/, mutation-checked against removing the early return.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 21:06:05 +02:00
mathiasandClaude Sonnet 5.5 6bf61d75c0 feat(watcher): inbox zero — archived raw/ originals are removed, not copied
CI / Lint / Test / Vet (pull_request) Successful in 2m27s
CI / Mirror to GitHub (pull_request) Skipped
After a note is archived into raw/processed/ or raw/failed/, remove the
original from raw/ so raw/ only holds unprocessed input. The .processed /
.failed marker is now only a fallback when the removal itself fails.
Existing markers are still honoured.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 20:59:06 +02:00
mathias 18d01b64d7 chore(context): restore the root rules in AGENTS.md and system-prompt.txt (#200) (#201)
CI / Lint / Test / Vet (push) Successful in 1m0s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-09 06:02:43 +00:00
mathiasandClaude Sonnet 5.5 5dfd2c0f7e docs: main is protected, ship by PR (local-dev#42)
CI / Lint / Test / Vet (push) Successful in 1m28s
CI / Mirror to GitHub (push) Successful in 3s
Gitea will refuse direct pushes to main in this repo. Rule 5's reviewed lane was enforced on the merge tools
(tbd_ship / pr_merge, gitea-mcp v0.17.0/v0.18.0) but not on a direct commit; this overrides rule 5's "risk/low straight
to main" for this repo and says why. Derived adapter files regenerated where the repo has them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
2026-10-08 08:34:50 +02:00
mathiasandClaude Opus 5.5 22d0653b61 test(watcher): a marker link planted during the run is refused and not retried (#158)
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
Review round 2 on PR #195: the marker writes had no test, so reverting
them to by-path writes, or the refusal to log-only, stayed green. The
link is planted from inside the fake model call, between the walk's
Lstat and the marker write, for both markers and both an outside and
an in-brain relative target; red on all four flips. The marker-entry
skip is now checked for .failed too.

Also pins cleanRel's acceptance of a backslash as a filename byte in
brainfs itself, and stops the buildAndWrite control claiming writes it
does not count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:47:24 +02:00
mathiasandClaude Opus 5.5 d6ddac4a03 fix(metrics): label an unmatched request "unmatched", never its raw path (#185)
CI / Mirror to GitHub (push) Successful in 5s
CI / Lint / Test / Vet (push) Successful in 1m4s
An authenticated REST request to an unregistered path reached the inner
mux, which left Pattern empty, and metrics.Middleware fell back to the
raw URL path: any token holder could create one Prometheus series per
unique path. The fallback is now the fixed label "unmatched"; real routes
keep their pattern. Tests: buildMux with an authenticated random path,
and the middleware unit test; the timing test now routes through a
ServeMux as production does.

Fixes #185.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:42:45 +02:00
mathiasandClaude Opus 5.5 1a2a93d2f4 fix(watcher): refusals end in a terminal state; pin every converted writer (#158)
CI / Lint / Test / Vet (pull_request) Successful in 1m6s
CI / Mirror to GitHub (pull_request) Skipped
Review round 1 on PR #195:
- A refused marker or archive write left no marker, so the note was
  reprocessed every interval (archive copies piling up; an LLM call per
  round on the failure path). The marker check now uses Lstat (any entry
  at the marker path counts, so a dangling marker link skips the note
  instead of looping), and a note whose archive copy or marker write is
  refused is remembered (path, size, mtime) and not retried until it
  changes.
- cleanRel no longer refuses a backslash: it is a legal filename byte on
  Linux, not a traversal, and a raw note named with one was never
  archived or marked.
- Tests now reach every converted writer: the log.md append (real
  raw/failed so the log is reached, symlinked log.md), a dangling marker
  link, the refused-retry loop with a positive control, a backslash name,
  a buildAndWrite swap race, and the claude-session archive with in-brain
  and outside symlinked archive dirs. The stale lint allowlist entry for
  appendWatcherLog is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:40:19 +02:00
mathiasandClaude Opus 5.5 4422dd1f69 test(symlinkguard): synchronise the alert-queue tests on observable completion (#193)
CI / Lint / Test / Vet (push) Successful in 1m3s
CI / Mirror to GitHub (push) Successful in 4s
Three Watch tests flaked under -race and CPU load (3/8 of 8 parallel
x10 runs on main):
- QueueIsBounded slept 100ms before looking for the summary; it now
  waits for the summary, then checks the bound.
- DeliversEveryQueuedAlert asserted FIFO order, but the outage ends
  asynchronously, possibly mid-flush, so a later item can send first. It
  now asserts each removal is delivered exactly once (order is not what
  it is named for).
- AccountsForEveryRemoval slept 50ms after the first summary, counted
  only "removed 1" and took the last summary's count. It now sums every
  "removed N" and every suppression count and waits until they account
  for all plants.

Stress: 0/8 failures in three rounds of 8 parallel x -test.count=10 under
-race. Flips still red: unbounded queue, dropped retries, suppressions
not counted.

Fixes #193.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:33:55 +02:00
mathiasandClaude Opus 5.5 f65f0b7826 fix(audit): each ntfy alert source sends its own title (#163)
CI / Lint / Test / Vet (push) Successful in 1m13s
CI / Mirror to GitHub (push) Successful in 4s
Every alert was titled "brain-capture audit degraded", including the
symlink tripwire's and governance-edit alerts. NtfyNotifier.WithTitle
returns a titled copy (nil stays nil); buildNotifier(getenv, title) is
called per source: capture audit (unchanged title), "brain symlink
tripwire", "brain governance edit". Tests assert the Title header.

Fixes #163.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:28:38 +02:00
mathiasandClaude Opus 5.5 77c44f298c fix: pipeline, backfill, watcher and archive writes go through the brain root (#158 part B)
CI / Lint / Test / Vet (pull_request) Successful in 1m13s
CI / Mirror to GitHub (pull_request) Skipped
The remaining writers created or rewrote brain files by path:
- pipeline buildAndWrite: os.MkdirAll + os.WriteFile of every page.
- BackfillRefs: os.WriteFile of each concept page; a page swapped for a
  symlink after the read was written through (the race test overwrote an
  in-brain target before this change).
- watcher: the raw/processed and raw/failed archive copies (os.Create),
  the .processed/.failed markers and the log.md entry (os.OpenFile).
- cmd/server: the claude-session archive.

All now go through brainfs PutRegular/AppendRegular (new package-level
PutFile/AppendFile for one-off writers: the watcher's call-site lint bans
.Open calls in that file so raw/ reads stay on OpenRegular).

Stacked on #189 (needs PutRegular/AppendRegular).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:26:02 +02:00
mathiasandClaude Opus 5.5 f4636c7ef0 fix: creating and generated writes go through the brain root (#188, #158 part A) (#189)
CI / Lint / Test / Vet (push) Failing after 1m2s
CI / Mirror to GitHub (push) Skipped
New brainfs PutRegular (create or replace), CreateRegular (create only), AppendRegular, Remove and RealParent: missing parents created one component at a time through the root, every existing component a real directory, new files created with O_EXCL and confirmed before writing. brain_write, promote/reject (O_EXCL create, source kept on refusal), StampProvenanceFile, BuildWingIndex, RebuildIndex, AppendLog and the tunnel candidate log use them; provenance.ApplyFile removed.

Fixes #188. Refs #158. Adversarial review: BLOCKED, BLOCKED, PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 21:25:54 +00:00
mathiasandClaude Opus 5.5 bd989f887f fix(linkcheck): keep the write when the link index cannot be built (#178) (#191)
CI / Lint / Test / Vet (push) Successful in 1m4s
CI / Mirror to GitHub (push) Successful in 4s
StripUnresolvedLinks logs a warning and returns the content unchanged when the link index cannot be built (e.g. an unreadable subdirectory), instead of failing every brain_write/brain_update and losing the learning. Nothing is unwrapped on a partial index.

Fixes #178.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 21:16:04 +00:00
mathiasandClaude Opus 5.5 4770f97ffd fix(symlinkguard): report and remove FIFOs, sockets and devices too (#179) (#190)
CI / Lint / Test / Vet (push) Successful in 1m5s
CI / Mirror to GitHub (push) Successful in 4s
Scan reports special files (not regular, directory or symlink) in Result.Special, so the startup check refuses with a message naming them; Watch removes them through an os.Root after re-checking the type, and reports any it cannot remove (never as removed).

Fixes #179. Adversarial review: BLOCKED, BLOCKED, PASS. Flake on main tracked in #193.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 21:15:57 +00:00
mathiasandClaude Opus 5.5 ddd4bdc2ac fix(brainfs): refuse a file behind a symlinked directory for reads and writes (#173) (#187)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 3s
ReadRegular, ReadRegularMax and WriteRegular require a clean, local path and a real directory at every level above the file, so an in-brain directory symlink (or a ".." after one) can no longer redirect brain_update, brain_tunnel, brain_read/get or the other readers elsewhere in the brain. session.Append opens through an os.Root on the brain dir, refuses a non-directory sessions dir, and writes only after confirming the log path is the regular file it opened.

Fixes #173. Adversarial review: BLOCKED, PASS. Follow-up: #188 (brain_write create path).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 20:17:37 +00:00
mathiasandClaude Opus 5.5 1fff54769d fix(tunnel): DetectTunnels walks and reads through brainfs (#174, #157 slice 5b) (#186)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
DetectTunnels (inside every brain_write/brain_update via AutoTunnel) walks with brainfs.WalkRegular and reads titles with ReadRegular: a FIFO under wiki/ no longer hangs the write, and a symlinked note's title no longer becomes a tunnel candidate. A wiki/ that is not a real directory yields no candidates; other walk errors still abort.

Fixes #174. Refs #157 (last reader slice). Adversarial review: BLOCKED, PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 20:12:44 +00:00
mathias 0c3e82ca0a test(brainfs): per-caller in-brain symlink targets for slices 1-3 (#171) (#172)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 19:36:31 +00:00
mathiasandClaude Opus 5.5 eedb110be5 fix(pipeline,classification): read the brain through brainfs (#157, slice 6) (#176)
CI / Lint / Test / Vet (push) Successful in 1m1s
CI / Mirror to GitHub (push) Successful in 4s
Ingest pipeline readers (readSourcePage and loadSchema feed the LLM prompt; reviseOrCreate; injectSourceRefs; BackfillRefs list and reads) and classification.Load read through brainfs: symlinked or special files no longer reach the prompt, written pages or the I1 sovereignty config, and a FIFO no longer hangs ingest or startup. A symlinked/special classification.yaml is a hard error; an absent one still yields defaults.

Refs #157. Adversarial review: BLOCKED, BLOCKED, PASS. Follow-ups: #158 (write paths), #179 (FIFO tripwire).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 18:54:42 +00:00
mathiasandClaude Opus 5.5 febab3e45d fix: brain_tunnel, brain_update and session_log confine caller-supplied paths (#169) (#170)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
brain_tunnel accepts only clean wiki/.../*.md paths and reads/rewrites both notes through brainfs; new brainfs.WriteRegular never creates, never writes through a final symlink (Lstat + O_NOFOLLOW|O_NONBLOCK + os.SameFile on the handle, since os.Root follows in-root final symlinks even with O_NOFOLLOW). brain_update resolves like brain_edit (knowledge/ and wiki/ notes only) and reads/rewrites through brainfs. session_log requires a plain-name session id and opens with O_NOFOLLOW|O_NONBLOCK.

Fixes #169. Adversarial review: BLOCKED, BLOCKED, PASS (round 3 by the estate session's reviewer). Follow-ups: #173, #174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 18:47:07 +00:00
mathias bdc1e51a1a fix(ingestion): build /mcp's bearer and the metrics wrap inside buildMux (#154) (#183)
CI / Lint / Test / Vet (push) Successful in 1m1s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 17:48:44 +00:00
mathias 8c48e2e343 fix(symlinkguard): deliver alerts beside the scan, not inside it (#164) (#180)
CI / Lint / Test / Vet (push) Successful in 54s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 14:48:16 +00:00
mathias c0be5c473c fix(ingestion): bump mcp-chassis to v0.7.1; log auth rejections; label refused REST requests by route (#181)
CI / Lint / Test / Vet (push) Successful in 1m4s
CI / Mirror to GitHub (push) Successful in 4s
Closes #155

Reviewed by pr-gatekeeper: #181 (comment) (block, round 1) and re-review at e05a13a (approve).

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 14:40:20 +00:00
mathiasandClaude Opus 5.5 0bd0fbf03e test(brainfstest): swap-race asserts fail when nothing was tested
CI / Lint / Test / Vet (push) Successful in 55s
CI / Mirror to GitHub (push) Successful in 4s
AssertNoLeakFrom and the search Query race test passed vacuously when
every symlink swap failed or every read errored. Both now count
successful symlink swaps and reads that returned the inside note and
fail if either is zero; the Query race reader no longer swallows
Query's error.

Verified by flipping: a swapper whose os.Symlink always fails turns
notes, wiki and search race tests red; Query on a missing brain dir
turns the search race test red.

Closes #177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:32:25 +02:00
mathiasandClaude Opus 5.5 ef39435252 fix(brain): wing indexes and the link check read through brainfs (#157, slice 5a) (#175)
CI / Lint / Test / Vet (push) Successful in 55s
CI / Mirror to GitHub (push) Successful in 5s
BuildWingIndex/BuildAllWingIndexes list wings and halls with the new brainfs.ReadDirDirs and ReadDirRegular and read titles with ReadRegular: a symlinked note no longer reaches _index.md, a symlinked wing is refused (nothing written into its target), a symlinked hall is skipped. linkcheck entryIndex walks with WalkRegular, so a symlinked .md cannot make a link resolve, and a linked tree that is not a real directory is skipped with a warning (brain_write still lands).

Refs #157. Adversarial review: BLOCKED, PASS. Follow-up: #178.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 07:26:12 +00:00
mathiasandClaude Opus 5.5 b6ef11600b fix(deps): bump x/net and x/text past called vulns
CI / Lint / Test / Vet (push) Successful in 1m45s
CI / Mirror to GitHub (push) Successful in 4s
govulncheck 2026-10-04 found called vulns in ingestion (brain-mcp, public),
reached via audit.LokiCentral.Push: golang.org/x/net v0.26.0
(GO-2026-4918 HTTP/2 infinite loop, GO-2026-5026) and golang.org/x/text
v0.29.0 (GO-2026-5970). x/net -> v0.55.0, x/text -> v0.39.0; tidy also
moved x/sync, x/sys, x/term. govulncheck ./... now: 0 called findings
(19 in required modules, none reachable).

Refs: govulncheck 2026-10-04

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:59:31 +02:00
mathiasandClaude Opus 5.5 432e09d122 test(ingestion): OIDC discovery 502s then 200 against a real issuer (#83)
The retry landed in 9a232ad but was tested only through startupretry with
fake funcs; nothing exercised chassisauth.NewJWTValidator's real 502 path.
Extracts the main() closure into buildJWTValidator (behaviour unchanged)
and tests it against an httptest issuer: 3x502 then 200 succeeds with 4
discoveries; a persistent 502 stops at exactly the 8-attempt budget with
the last error; cancelling ctx mid-backoff returns context.Canceled.
Flip-verified: Attempts=1 turns all three red.

Lane: risk/medium issue, committed to main because the behaviour change
already shipped in 9a232ad; this is a test plus a pure extraction.

Closes #83

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:57:45 +02:00
mathiasandClaude Opus 5.5 77e32de33c fix(graph,wiki): read the brain through brainfs (#157, slice 4) (#168)
CI / Lint / Test / Vet (push) Successful in 52s
CI / Mirror to GitHub (push) Successful in 4s
graphsync, wiki inventory/index read through brainfs; brainfs refuses an in-brain file swapped in behind a symlink (os.SameFile). Gatekeeper round 2 PASS on d90ce66, CI run 9180 green.

Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 04:53:34 +00:00
95 changed files with 8011 additions and 802 deletions
+12
View File
@@ -84,3 +84,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+81 -17
View File
@@ -164,6 +164,63 @@ These rules apply to every task across every project, regardless of harness.
the thing it was named for was exploitable. If you cannot name the mechanism, the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes. you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns ## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`): Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -244,25 +301,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders: Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count | | Folder | Role | Rules that key off it |
|--------|-------|-------| |--------|------|-----------------------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | | `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | | `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | | `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | | `spikes/` | throwaway | no ceremony |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
### Key active projects Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it ## Knowledge base — actively use it
@@ -514,4 +566,16 @@ When acting as a coding agent on this project:
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
--- ---
+81 -17
View File
@@ -159,6 +159,63 @@ These rules apply to every task across every project, regardless of harness.
the thing it was named for was exploitable. If you cannot name the mechanism, the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes. you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns ## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`): Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -239,25 +296,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders: Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count | | Folder | Role | Rules that key off it |
|--------|-------|-------| |--------|------|-----------------------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | | `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | | `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | | `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | | `spikes/` | throwaway | no ceremony |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
### Key active projects Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it ## Knowledge base — actively use it
@@ -508,3 +560,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+12
View File
@@ -84,3 +84,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+57
View File
@@ -0,0 +1,57 @@
# The raw/ inbox
`raw/` is an inbox: only unprocessed material sits there. Syncthing shares it
with the machines you drop files from. (hyperguild#205, #208, #209)
## What happens to a file you drop
| You drop | Result |
|---|---|
| `raw/clip.md` (no folder, no `wing:`) | extracted and filed as a source in `wiki/web-clip/sources/`; its concepts and entities update the shared `wiki/concepts/` and `wiki/entities/` pages; the original moves to `raw/processed/<date>/`, so `raw/` is empty again |
| `raw/<wing>/clip.md` or `wing: <wing>` in the frontmatter | the same, with the source in `wiki/<wing>/sources/`. The wing must already exist; a typo is parked, never created. This is your deliberate choice and is not second-guessed |
| a note whose text matches the client-content pattern, with no folder | parked in `raw/needs-review/`, not extracted |
| a folder or `wing:` naming an unknown, reserved or non-ASCII wing, or two disagreeing signals | parked in `raw/needs-review/` |
| a note over the size cap, or one that cannot be archived | stays in `raw/`, reported once, not retried until it changes |
| a note the model returned nothing usable for (no source page could be written) | fails: the note is kept in `raw/failed/` and the reason is in `log.md`; it is never archived as processed |
Concepts and entities are shared across wings because their slug is the graph's
identity. A source whose slug is taken gets a wing-qualified one (`notes-homelab`).
## Parked notes
`brain_pending` lists them with `queue: needs-review`, a proposed wing and the
reason. To act on one: `brain_promote` / `brain_reject` (pass `queue` when the
same name also sits in the inbox), or move the file into `raw/<wing>/` to have it
extracted. Parked pdf/txt files are listed but can only be moved or rejected.
## Switching it on
Routing is off by default (the retired layout is used). Set on the ingestion
deployment:
| Variable | Meaning |
|---|---|
| `INGEST_WING_ROUTING` | `1`/`true` enables the rules above; unset or `0`/`false` is off; any other value stops the server at startup |
| `INGEST_DEFAULT_WING` | wing for unsignalled notes (default `web-clip`; must be a plain name, not `sources`/`concepts`/`entities`/`wiki`) |
| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`, which the deployment injects as a bare list of client names (`acme|globex`): it is then matched as whole names, with word edges decided by Unicode letters and digits (an underscore or punctuation is a boundary, so `trucker_ecommerce` matches `trucker`; `acmecorp`, `acme2` and plurals do not), and a list that is not a valid regexp, has an empty alternative, or could break out of its group stops the server at startup. A full pattern that matches the empty string is refused at startup too. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. Patterns with spaces, `\\s` or `\\b` work (whitespace is collapsed in one view and removed in another). It catches what it names and nothing else: leetspeak, punctuation splits (`a.c.m.e`), encodings and homoglyphs outside a small Cyrillic/Greek table are not caught. Large notes are checked in 1 MiB chunks |
A bad default or pattern stops the server at startup. A wing created while the
server runs is usable without a restart.
## Known limits
- The client filter works on 1 MiB chunks that overlap by 4 KiB. A match that can
span more than 4 KiB (crafted padding between two words of a multi-word
pattern, exactly at a chunk edge) is not guaranteed to be found; this affects
notes over 1 MiB only. On a 64 MiB note the check takes about ten seconds.
- A pattern that matches the empty string (`.*`, `x*`) parks every unsignalled
note. That fails closed and only the operator's own configuration can do it.
- A deliberate folder into a sensitive wing places the note there, and the shared
concept/entity pages then cite that source by title. Client material does not
belong in the brain at all; the pattern only guards the unsignalled lane.
- A merge into a shared page refreshes `last_updated` and `origin` to the latest
ingest; the first wing's attribution is not kept.
- A marker file (`<note>.processed`) is only written when a processed original
cannot be removed. One at least as new as its note holds the note (and says
so once); an older or future-dated one is ignored and removed.
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"context"
"fmt"
"log/slog"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
)
// buildJWTValidator builds the JWT validator for issuer (nil when issuer is
// empty: JWT auth disabled). Since mcp-chassis v0.7 construction no longer
// fails when OIDC discovery does (mcp-chassis#4): the issuer resolves lazily,
// and until it does a JWT is answered 503-and-retry, not 401. That replaces
// #83's boot-time retry loop (hyperguild#155). An issuer still unresolved at
// boot is logged, so a misconfigured issuer is visible rather than silent.
func buildJWTValidator(ctx context.Context, logger *slog.Logger, issuer, audience string) (*chassisauth.JWTValidator, error) {
v, err := chassisauth.NewJWTValidator(ctx, issuer, audience)
if err != nil {
return nil, fmt.Errorf("build jwt validator: %w", err)
}
if ok, rerr := v.Ready(ctx); !ok {
logger.Warn("jwt issuer not resolved at boot; JWTs answer 503 until it is", "issuer", issuer, "err", rerr)
}
return v, nil
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
)
// 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
}
// #83 retried discovery at boot because chassis v0.2.0 failed construction on
// a 502. Since chassis v0.7 (mcp-chassis#4) the validator is built anyway and
// resolves the issuer lazily, so boot no longer waits or exits on it
// (hyperguild#155). An unresolved issuer is logged at boot.
func TestBuildJWTValidatorReturnsAValidatorWhileDiscovery502s(t *testing.T) {
srv, _ := fakeIssuer(t, 1000)
var buf bytes.Buffer
v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain")
require.NoError(t, err)
assert.NotNil(t, v)
assert.Contains(t, buf.String(), "jwt issuer not resolved at boot")
}
func TestBuildJWTValidatorLogsNothingAgainstAHealthyIssuer(t *testing.T) {
srv, _ := fakeIssuer(t, 0)
var buf bytes.Buffer
v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain")
require.NoError(t, err)
assert.NotNil(t, v)
assert.NotContains(t, buf.String(), "not resolved")
}
func TestBuildJWTValidatorIsNilWithoutAnIssuer(t *testing.T) {
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), "", "brain")
require.NoError(t, err)
assert.Nil(t, v)
}
// With the issuer unresolved, a JWT on a REST route answers 503 (retry), not
// 401: the token may be fine, the server just cannot check it yet.
func TestBuildMuxAnswers503ToAJWTWhileTheIssuerIsUnresolved(t *testing.T) {
srv, _ := fakeIssuer(t, 1000)
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), srv.URL, "brain")
require.NoError(t, err)
mux, err := buildMux(restAuth{token: routesTok, validator: v, realm: "brain"}, testHandler(t),
okHandler("mcp"), metrics.New(), nil)
require.NoError(t, err)
code, _ := do(t, mux, "GET", "/pending", "Bearer aaa.bbb.ccc")
assert.Equal(t, http.StatusServiceUnavailable, code)
}
// PR #183 review: /mcp shares REST's bearer, but only the static token was
// asserted on it. With the issuer unresolved, a JWT on /mcp answers 503 too,
// so /mcp's middleware was built with the JWT validator.
func TestBuildMuxAnswers503ToAJWTOnMCPWhileTheIssuerIsUnresolved(t *testing.T) {
srv, _ := fakeIssuer(t, 1000)
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), srv.URL, "brain")
require.NoError(t, err)
mux, err := buildMux(restAuth{token: routesTok, validator: v, realm: "brain"}, testHandler(t),
okHandler("mcp"), metrics.New(), nil)
require.NoError(t, err)
code, _ := do(t, mux, "POST", "/mcp", "Bearer aaa.bbb.ccc")
assert.Equal(t, http.StatusServiceUnavailable, code)
}
// PR #183 review: a 401 on /mcp names the protected-resource metadata in
// WWW-Authenticate (what the claude.ai OAuth connector discovers from), so
// /mcp's middleware was built with resourceMetadataURL.
func TestBuildMuxMCP401CarriesResourceMetadataInWWWAuthenticate(t *testing.T) {
const rm = "https://brain.example/.well-known/oauth-protected-resource"
mux, err := buildMux(restAuth{token: routesTok, realm: "brain", resourceMetadataURL: rm}, testHandler(t),
okHandler("mcp"), metrics.New(), nil)
require.NoError(t, err)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))
require.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Contains(t, rec.Header().Get("WWW-Authenticate"), `resource_metadata="`+rm+`"`)
}
+103 -49
View File
@@ -17,6 +17,7 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/audit" "github.com/mathiasbq/hyperguild/ingestion/internal/audit"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/capture" "github.com/mathiasbq/hyperguild/ingestion/internal/capture"
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp" "github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
"github.com/mathiasbq/hyperguild/ingestion/internal/classification" "github.com/mathiasbq/hyperguild/ingestion/internal/classification"
@@ -32,11 +33,11 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"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/symlinkguard" "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"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd"
@@ -90,11 +91,15 @@ func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
sb.WriteString("\n\n") sb.WriteString("\n\n")
} }
slug := "session-" + b.Host + "-" + b.SessionID slug := "session-" + b.Host + "-" + b.SessionID
dest := filepath.Join(s.brainDir, "archive", "claude-sessions", b.Host, slug+".md") dest := "archive/claude-sessions/" + b.Host + "/" + slug + ".md"
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { // Written through the brain root, never through a symlinked directory
return fmt.Errorf("create claude-sessions archive dir: %w", err) // or file (hyperguild#158).
bfs, err := brainfs.Open(s.brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
} }
if err := os.WriteFile(dest, []byte(sb.String()), 0o644); err != nil { defer func() { _ = bfs.Close() }()
if err := bfs.PutRegular(dest, []byte(sb.String())); err != nil {
return fmt.Errorf("write claude session archive: %w", err) return fmt.Errorf("write claude session archive: %w", err)
} }
s.logger.Debug("claude session archived (non-indexed)", "path", dest) s.logger.Debug("claude session archived (non-indexed)", "path", dest)
@@ -145,6 +150,37 @@ func envInt(key string, fallback int) int {
return fallback return fallback
} }
// ingestClientPattern picks the client-content pattern for raw/ routing.
// INGEST_CLIENT_BLOCK is a complete regexp and is used as is. Otherwise the
// shared CLAUDE_INGEST_CLIENT_BLOCK is used: the deployment injects it as a bare
// list of client names (regexp alternation), which wing.WrapNames bounds so a
// short name does not match inside ordinary words, and which it refuses if it
// would break out of its group or match the empty string.
func ingestClientPattern(getenv func(string) string) (string, error) {
if p := getenv("INGEST_CLIENT_BLOCK"); p != "" {
return p, nil
}
if names := getenv("CLAUDE_INGEST_CLIENT_BLOCK"); names != "" {
return wing.WrapNames(names)
}
return "", nil
}
// envFlag reads a boolean switch. Unset is off; a value strconv.ParseBool does
// not understand is an error, so a typo ("ture") cannot silently leave a
// safety-relevant switch off.
func envFlag(key string) (bool, error) {
v := os.Getenv(key)
if v == "" {
return false, nil
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("%s=%q: want 1, 0, true or false", key, v)
}
return b, nil
}
// buildNotifier returns the ntfy notifier, or a nil interface when // buildNotifier returns the ntfy notifier, or a nil interface when
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so // BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
// DegradingSink/Reconcile and the governance observer skip it cleanly. // DegradingSink/Reconcile and the governance observer skip it cleanly.
@@ -180,9 +216,12 @@ func alertingWarnings(getenv func(string) string) []string {
return out return out
} }
func buildNotifier() audit.Notifier { // buildNotifier returns the ntfy notifier for one alert source, titled so
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil { // the source is recognisable (hyperguild#163), or nil when ntfy is not
return n // configured.
func buildNotifier(getenv func(string) string, title string) audit.Notifier {
if n := audit.NewNtfyNotifier(getenv("BRAIN_NTFY_URL"), getenv("BRAIN_NTFY_TOKEN")); n != nil {
return n.WithTitle(title)
} }
return nil return nil
} }
@@ -204,7 +243,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
logger.Error("capture audit buffer init", "err", err) logger.Error("capture audit buffer init", "err", err)
os.Exit(1) os.Exit(1)
} }
notifier := buildNotifier() notifier := buildNotifier(os.Getenv, "brain-capture audit degraded")
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval) audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds())) logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
@@ -367,29 +406,17 @@ func main() {
ctx := context.Background() ctx := context.Background()
// hyperguild#157: report a symlink that appears in the brain while running. // hyperguild#157: report a symlink that appears in the brain while running.
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier()) go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier(os.Getenv, "brain symlink tripwire"))
// Built before the watcher, the graph backfill and session ingestion, and // Never fatal on an unreachable issuer (#83): on a cold cluster boot this
// retried rather than fatal (#83). Two separate reasons: // pod can beat dex to serving and get a 502 from ingress, and exiting meant
// // CrashLoopBackOff redoing the backfill. Since mcp-chassis v0.7 the
// - On a cold cluster boot this pod can beat dex to serving and gets a // validator resolves the issuer lazily and answers JWTs 503-and-retry until
// 502 from ingress. Nothing is misconfigured; it simply started first. // it does (hyperguild#155), so no boot retry is needed. A misconfigured
// Exiting there meant CrashLoopBackOff redoing the backfill on every // issuer no longer exits the process: it is logged at boot and every JWT
// attempt, with exponential backoff stretching the brain outage. // gets 503; the static bearer keeps working.
// - Constructing it first means a genuine config error (wrong issuer, jwtValidator, err := buildJWTValidator(ctx, logger,
// bad audience) fails in seconds instead of after all that work. os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
//
// The budget stays bounded: retrying forever would turn a misconfiguration
// into a process that looks alive and never serves.
jwtValidator, err := startupretry.Do(ctx,
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second},
func(ctx context.Context) (*chassisauth.JWTValidator, error) {
v, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
if err != nil {
logger.Warn("oidc discovery not ready, retrying", "err", err)
}
return v, err
})
if err != nil { if err != nil {
logger.Error("build jwt validator", "err", err) logger.Error("build jwt validator", "err", err)
os.Exit(1) os.Exit(1)
@@ -399,10 +426,40 @@ func main() {
} }
if watchInterval > 0 { if watchInterval > 0 {
// INGEST_WING_ROUTING=1 files raw/ notes by rule (hyperguild#208): a
// deliberate raw/<wing>/ folder or `wing:` frontmatter, else the default
// wing (INGEST_DEFAULT_WING, "web-clip"); a note whose text matches the
// client-content pattern (INGEST_CLIENT_BLOCK, else
// CLAUDE_INGEST_CLIENT_BLOCK) or that names an unknown wing is parked in
// raw/needs-review/. Off = the retired layout. Set explicitly, a bad
// default or pattern is fatal: silently falling back would write into
// the layout this flag exists to leave. The interface stays nil when off
// (a nil *Router inside it would not be nil).
var placer wing.Placer
routing, ferr := envFlag("INGEST_WING_ROUTING")
if ferr != nil {
logger.Error("INGEST_WING_ROUTING", "err", ferr)
os.Exit(1)
}
if routing {
pattern, perr := ingestClientPattern(os.Getenv)
if perr != nil {
logger.Error("client-content pattern", "err", perr)
os.Exit(1)
}
router, rerr := wing.NewRouter(brainDir, envOr("INGEST_DEFAULT_WING", "web-clip"), pattern)
if rerr != nil {
logger.Error("build wing router", "err", rerr)
os.Exit(1)
}
placer = router
logger.Info("wing routing enabled", "default_wing", router.Default, "client_filter", router.ClientBlock != nil)
}
watcher.Start(ctx, watcher.Config{ watcher.Start(ctx, watcher.Config{
BrainDir: brainDir, BrainDir: brainDir,
Interval: time.Duration(watchInterval) * time.Second, Interval: time.Duration(watchInterval) * time.Second,
Pipeline: pipelineCfg, Pipeline: pipelineCfg,
Resolver: placer,
}) })
} }
@@ -529,8 +586,6 @@ func main() {
} }
} }
public["/mcp"] = chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)
// 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
// configured. It reuses the MCP server's graph-wired brain store (one // configured. It reuses the MCP server's graph-wired brain store (one
@@ -554,14 +609,14 @@ func main() {
public["POST /capture"] = captureH public["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. buildMux wraps this same mcpSrv;
// WithCapture mutates the same instance, so the tool appears live. // WithCapture mutates the same instance, so the tool appears live.
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver) mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
// hyperguild#97: a governance-file rewrite gets the same trail a // hyperguild#97: a governance-file rewrite gets the same trail a
// capture already gets, plus an out-of-band alert. Wired here because // capture already gets, plus an out-of-band alert. Wired here because
// brain_edit_root needs the capture plumbing anyway — that is what // brain_edit_root needs the capture plumbing anyway — that is what
// re-derives the static principal its gate checks. // re-derives the static principal its gate checks.
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier()) mcpSrv.WithGovernanceObserver(auditSink, buildNotifier(os.Getenv, "brain governance edit"))
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign), logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
"governance_observer", true) "governance_observer", true)
} else { } else {
@@ -596,19 +651,18 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// /metrics — unauthenticated Prometheus endpoint. kube-prometheus-stack // /metrics — unauthenticated Prometheus endpoint, scraped via the
// scrapes it via the ServiceMonitor in k3s/apps/supervisor/. The metrics // ServiceMonitor in k3s/apps/supervisor/. buildMux registers it and wraps
// middleware below wraps every other registered handler so it observes // the whole router in the registry's middleware.
// real request latency. /metrics itself is excluded from its own //
// observation by registering it on the outer mux (post-wrap). // The whole router, /mcp's bearer included, is built in buildMux so its
reg := metrics.New() // tests are tests of production wiring (PR #152 review, hyperguild#154).
public["GET /metrics"] = http.HandlerFunc(reg.Handler()) // The REST API's credential is the same as /mcp's. Untested here: the
// restAuth fields and the public map this call passes in (e.g. a nil
// The whole router, built in one place so the test of buildMux is a test // validator would drop JWT auth on /mcp and REST and the suite stays
// of production wiring (PR #152 review). The REST API's credential is the // green), and ListenAndServe serving the result unchanged (hyperguild#182).
// same as /mcp's. handler, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain",
mux, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain", resourceMetadataURL: resourceMetadataURL}, h, mcpSrv, metrics.New(), public)
resourceMetadataURL: resourceMetadataURL}, h, public)
if err != nil { if err != nil {
logger.Error("build router", "err", err) logger.Error("build router", "err", err)
os.Exit(1) os.Exit(1)
@@ -629,7 +683,7 @@ func main() {
"watch_interval", watchIntervalLog, "watch_interval", watchIntervalLog,
"mcp_enabled", true, "mcp_enabled", true,
) )
if err := http.ListenAndServe(addr, reg.Middleware(mux)); err != nil { if err := http.ListenAndServe(addr, handler); err != nil {
logger.Error("server stopped", "err", err) logger.Error("server stopped", "err", err)
os.Exit(1) os.Exit(1)
} }
+80
View File
@@ -76,3 +76,83 @@ func TestAlertingWarnings(t *testing.T) {
}) })
} }
} }
// hyperguild#158: the claude-session archive was written with os.MkdirAll +
// os.WriteFile by path, so a symlinked archive directory redirected it.
func TestClaudeSinkDoesNotWriteThroughASymlinkedArchiveDir(t *testing.T) {
for name, mk := range map[string]func(t *testing.T, dir string) (link, target string){
"outside": func(t *testing.T, _ string) (string, string) { o := t.TempDir(); return o, o },
"in-brain": func(t *testing.T, dir string) (string, string) {
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
return filepath.Join("..", "raw"), filepath.Join(dir, "raw")
},
} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
link, target := mk(t, dir)
require.NoError(t, os.Symlink(link, filepath.Join(dir, "archive")))
sink := &claudeSink{brainDir: dir, logger: slog.New(slog.NewTextHandler(os.Stderr, nil))}
err := sink.Ingest(context.Background(), claudewatcher.Batch{Host: "koala", SessionID: "abc",
Turns: []claudewatcher.Turn{{Type: "assistant", Content: "x"}}})
assert.Error(t, err)
ents, _ := os.ReadDir(target)
assert.Empty(t, ents, "the archive was written at the link's target")
})
}
}
func TestEnvFlag(t *testing.T) {
tests := []struct {
name, set string
want bool
wantErr bool
}{
{"unset is off", "", false, false},
{"1", "1", true, false},
{"true", "true", true, false},
{"TRUE", "TRUE", true, false},
{"0", "0", false, false},
{"false", "false", false, false},
{"a typo is an error, not silently off", "ture", false, true},
{"yes is not a bool here", "yes", false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("TEST_ENV_FLAG", tt.set)
got, err := envFlag("TEST_ENV_FLAG")
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
// The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as a bare list of client
// names (regexp alternation). Used raw, a short name would match inside ordinary
// words; wrapped with \b, a name with a non-ASCII edge letter would never match.
func TestIngestClientPattern(t *testing.T) {
get := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
t.Run("nothing set", func(t *testing.T) {
p, err := ingestClientPattern(get(map[string]string{}))
assert.NoError(t, err)
assert.Empty(t, p)
})
t.Run("explicit pattern is a full regexp, used as is", func(t *testing.T) {
p, err := ingestClientPattern(get(map[string]string{"INGEST_CLIENT_BLOCK": `acme\s+corp`, "CLAUDE_INGEST_CLIENT_BLOCK": "other"}))
assert.NoError(t, err)
assert.Equal(t, `acme\s+corp`, p)
})
t.Run("the shared names are wrapped", func(t *testing.T) {
p, err := ingestClientPattern(get(map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "dif|trucker"}))
assert.NoError(t, err)
assert.Contains(t, p, "dif|trucker")
assert.NotEqual(t, "dif|trucker", p)
})
t.Run("a shared value that would break out of the group is a startup error", func(t *testing.T) {
_, err := ingestClientPattern(get(map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "x)|.*("}))
assert.Error(t, err)
})
}
+51 -19
View File
@@ -8,22 +8,22 @@ import (
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth" 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/metrics"
) )
// publicRoutes is the complete set of patterns allowed on the outer mux, // publicRoutes is the complete set of patterns a caller may register on the
// outside the REST API's auth (hyperguild#149). Each authenticates itself or is // outer mux, outside the REST API's auth (hyperguild#149). Each authenticates
// meant to be open: /mcp (bearer/JWT), /capture (bearer/JWT), the webhook // itself or is meant to be open: /capture (bearer/JWT), the webhook (HMAC),
// (HMAC), /oauth/token (client secret), OAuth metadata and /metrics (open). // /oauth/token (client secret) and OAuth metadata (open). /mcp and /metrics
// A REST pattern added here would beat the authenticated catch-all by // are not here: buildMux builds them itself (hyperguild#154). A REST pattern
// specificity and ship open, which is why buildMux refuses anything else. // 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{ var publicRoutes = map[string]bool{
"/mcp": true,
"POST /capture": true, "POST /capture": true,
"POST /webhooks/brain-sync": true, "POST /webhooks/brain-sync": true,
"GET /.well-known/oauth-protected-resource": true, "GET /.well-known/oauth-protected-resource": true,
"GET /.well-known/oauth-authorization-server": true, "GET /.well-known/oauth-authorization-server": true,
"POST /oauth/token": true, "POST /oauth/token": true,
"GET /metrics": true,
} }
// restAuth is the credential the REST API checks: the same as /mcp's. // restAuth is the credential the REST API checks: the same as /mcp's.
@@ -34,16 +34,25 @@ type restAuth struct {
resourceMetadataURL string resourceMetadataURL string
} }
// buildMux is the server's whole router. The REST API is mounted at "/" // buildMux builds the handler main passes to ListenAndServe unchanged. The
// behind bearer auth that buildMux constructs itself, so a caller cannot pass // REST API is mounted at "/" and /mcp is registered, both behind bearer auth
// a no-op; public handlers are registered as given, and only for patterns on // that buildMux constructs itself from auth (one credential), so a caller
// the publicRoutes allowlist. It returns an http.Handler, not the // cannot pass a no-op. GET /metrics serves reg, and the whole router is
// *ServeMux, so nothing can register a route on the result afterwards and // wrapped in reg's middleware. Other public handlers are registered as given,
// bypass the allowlist. // and only for patterns on the publicRoutes allowlist. It returns an
func buildMux(auth restAuth, h *api.Handler, public map[string]http.Handler) (http.Handler, error) { // http.Handler, not the *ServeMux, so nothing can register a route on the
// result. What stays untested is main's call site: the restAuth fields and
// the public map main passes in, and ListenAndServe serving this handler
// unchanged (hyperguild#182). The tests build their own arguments.
func buildMux(
auth restAuth, h *api.Handler, mcpHandler http.Handler, reg *metrics.Registry, public map[string]http.Handler,
) (http.Handler, error) {
if auth.token == "" { if auth.token == "" {
return nil, errors.New("buildMux: REST auth needs a token") return nil, errors.New("buildMux: REST auth needs a token")
} }
if mcpHandler == nil || reg == nil {
return nil, errors.New("buildMux: needs the MCP handler and the metrics registry")
}
mux := http.NewServeMux() mux := http.NewServeMux()
for pattern, handler := range public { for pattern, handler := range public {
if !publicRoutes[pattern] { if !publicRoutes[pattern] {
@@ -51,10 +60,15 @@ func buildMux(auth restAuth, h *api.Handler, public map[string]http.Handler) (ht
} }
mux.Handle(pattern, handler) mux.Handle(pattern, handler)
} }
mountREST(mux, h, func(next http.Handler) http.Handler { bearer := func(next http.Handler) http.Handler {
return chassisauth.BearerMiddleware(auth.token, auth.validator, auth.realm, auth.resourceMetadataURL, next) return chassisauth.BearerMiddleware(auth.token, auth.validator, auth.realm, auth.resourceMetadataURL, next)
}) }
return mux, nil mux.Handle("/mcp", bearer(mcpHandler))
// /metrics is registered on the mux and so observed by its own
// middleware, like any route.
mux.Handle("GET /metrics", reg.Handler())
mountREST(mux, h, bearer)
return reg.Middleware(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
@@ -73,5 +87,23 @@ func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.
rest.HandleFunc("POST /promote", h.Promote) rest.HandleFunc("POST /promote", h.Promote)
rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings) rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
rest.HandleFunc("GET /pass-rate", h.PassRate) rest.HandleFunc("GET /pass-rate", h.PassRate)
mux.Handle("/", auth(rest)) mux.Handle("/", labelRESTPattern(rest, auth(rest)))
}
// labelRESTPattern sets the request's Pattern to the REST route it addresses
// before auth runs, so the metrics middleware (which reads Pattern from the
// same *http.Request) labels a refused request by its route rather than by
// the "/" mount (hyperguild#155). A refused request to a path no route
// matches keeps "/", so for refused requests the label set stays bounded by
// the registered routes. That bound does not hold for authenticated
// requests: an unregistered path reaches the REST mux, which 404s with an
// empty Pattern, and the metrics middleware falls back to the raw URL path
// (hyperguild#185).
func labelRESTPattern(rest *http.ServeMux, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, pattern := rest.Handler(r); pattern != "" {
r.Pattern = pattern
}
next.ServeHTTP(w, r)
})
} }
@@ -0,0 +1,88 @@
package main
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// wrongTokSentinel is a presented credential distinctive enough that any
// occurrence in a log line is the raw token, not a coincidence.
const wrongTokSentinel = "wrong-tok-sentinel-q7zx"
// hyperguild#155: since #149 every REST route sits behind BearerMiddleware,
// and chassis v0.2.0 refused without logging, so an unknown caller got
// silent 401s. The chassis now writes an `mcp auth rejected` line, and the
// presented token appears nowhere in the log as a bare value (PR #181
// review: asserting on the "Bearer " header form could never fail, since
// the chassis logs fields, not headers).
func TestBuildMuxRejectionLogLineOmitsTheBarePresentedToken(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
code, _ := do(t, testMux(t), "POST", "/query", "Bearer "+wrongTokSentinel)
require.Equal(t, http.StatusUnauthorized, code)
assert.Contains(t, buf.String(), "mcp auth rejected")
assert.Contains(t, buf.String(), "path=/query")
assert.NotContains(t, buf.String(), wrongTokSentinel, "the rejected token is logged as a bare value")
assert.NotContains(t, buf.String(), routesTok, "the valid static token is logged")
}
// The `mcp auth accepted` line leaves out the valid static token.
func TestBuildMuxAcceptedLogLineOmitsTheBareStaticToken(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
code, _ := do(t, testMux(t), "GET", "/pending", "Bearer "+routesTok)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, buf.String(), "mcp auth accepted")
assert.NotContains(t, buf.String(), routesTok, "the accepted static token is logged as a bare value")
}
// An authenticated REST request is labelled with the REST mux's pattern:
// the inner mux sets it on the same *http.Request the outer middleware reads.
func TestMetricsLabelAnAuthenticatedRESTRequestWithItsRoutePattern(t *testing.T) {
mux, reg := testMuxAndRegistry(t)
code, _ := do(t, mux, "GET", "/pending", "Bearer "+routesTok)
require.Equal(t, http.StatusOK, code)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Contains(t, rec.Body.String(), `path="GET /pending"`)
assert.NotContains(t, rec.Body.String(), `path="/"`)
}
// hyperguild#155: a request refused by REST auth never reaches the REST mux,
// so it was labelled path="/" and a 4xx on /query could not be told from one
// on /write. It is now labelled with the REST pattern it was addressed to.
func TestMetricsLabelARejectedRESTRequestWithItsRoutePattern(t *testing.T) {
mux, reg := testMuxAndRegistry(t)
code, _ := do(t, mux, "POST", "/query", "")
require.Equal(t, http.StatusUnauthorized, code)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Contains(t, rec.Body.String(), `path="POST /query",status="4xx"`)
}
// A path no REST route matches keeps the catch-all label, so an attacker
// probing random URLs cannot grow the series set.
func TestMetricsLabelARejectedUnknownPathWithTheCatchAll(t *testing.T) {
mux, reg := testMuxAndRegistry(t)
code, _ := do(t, mux, "POST", "/no-such-route-zq", "")
require.Equal(t, http.StatusUnauthorized, code)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Contains(t, rec.Body.String(), `path="/",status="4xx"`)
assert.NotContains(t, rec.Body.String(), "no-such-route-zq")
}
+80 -12
View File
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
) )
@@ -21,20 +22,34 @@ func okHandler(body string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
} }
// testMux builds the router exactly as main does: buildMux with the full // testMux builds the router through buildMux, the function main calls, with
// public set main registers (stub handlers) and real bearer config. // every allowlisted public pattern (stub handlers), a stub MCP server and a
// hyperguild#149: the REST API is authenticated as a whole; the PR #152 // static token only: no JWT validator and no resourceMetadataURL (the tests
// review showed a test of a COPY of the router let main.go regress unseen. // in jwt_validator_test.go pass those). It does not use main's own restAuth
// or public map (hyperguild#182). 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 { 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{}) mux, _ := testMuxAndRegistry(t)
return mux
}
func testMuxAndRegistry(t *testing.T) (http.Handler, *metrics.Registry) {
t.Helper()
public := map[string]http.Handler{} public := map[string]http.Handler{}
for p := range publicRoutes { for p := range publicRoutes {
public[p] = okHandler("public " + p) public[p] = okHandler("public " + p)
} }
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, public) reg := metrics.New()
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, testHandler(t), okHandler("mcp"), reg, public)
require.NoError(t, err) require.NoError(t, err)
return mux return mux, reg
}
func testHandler(t *testing.T) *api.Handler {
t.Helper()
return api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
} }
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, string) {
@@ -74,8 +89,9 @@ func TestBuildMuxAuthenticatesAnUnregisteredPath(t *testing.T) {
} }
// buildMux registers public handlers as given, without adding auth: each // buildMux registers public handlers as given, without adding auth: each
// public route owns its own (bearer on /mcp, HMAC on the webhook, client // public route owns its own (bearer/JWT on /capture, HMAC on the webhook,
// secret on /oauth/token) or is meant to be open (/metrics, OAuth metadata). // client secret on /oauth/token) or is meant to be open (OAuth metadata).
// /mcp and /metrics are not public routes: buildMux builds them itself.
func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) { func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
r := testMux(t) r := testMux(t)
for p := range publicRoutes { for p := range publicRoutes {
@@ -93,14 +109,66 @@ func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
// over the authenticated catch-all (more specific pattern), so it is refused. // over the authenticated catch-all (more specific pattern), so it is refused.
func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) { func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) {
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{})
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics"} { // "/mcp" and "GET /metrics" are built by buildMux itself (hyperguild#154):
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, map[string]http.Handler{p: okHandler("x")}) // passing either as public would register them unauthenticated or twice.
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics", "/mcp", "GET /metrics"} {
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, okHandler("mcp"), metrics.New(),
map[string]http.Handler{p: okHandler("x")})
assert.Error(t, err, "public pattern %q must be refused", p) assert.Error(t, err, "public pattern %q must be refused", p)
} }
} }
func TestBuildMuxRefusesAnEmptyToken(t *testing.T) { func TestBuildMuxRefusesAnEmptyToken(t *testing.T) {
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{})
_, err := buildMux(restAuth{token: "", realm: "brain"}, h, nil) _, err := buildMux(restAuth{token: "", realm: "brain"}, h, okHandler("mcp"), metrics.New(), nil)
assert.Error(t, err) assert.Error(t, err)
} }
// hyperguild#154: /mcp's bearer was built in main.go, untested; the buildMux
// tests used stubs. buildMux now wraps the MCP handler itself, with the same
// credential as the REST API.
func TestBuildMuxRefusesMCPWithoutAValidToken(t *testing.T) {
r := testMux(t)
for _, authz := range []string{"", "Bearer wrong"} {
code, body := do(t, r, "POST", "/mcp", authz)
assert.Equal(t, http.StatusUnauthorized, code, "authz %q", authz)
assert.NotEqual(t, "mcp", body)
}
code, body := do(t, r, "POST", "/mcp", "Bearer "+routesTok)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, "mcp", body)
}
func TestBuildMuxRefusesANilMCPHandlerOrRegistry(t *testing.T) {
auth := restAuth{token: routesTok, realm: "brain"}
_, err := buildMux(auth, testHandler(t), nil, metrics.New(), nil)
assert.Error(t, err)
_, err = buildMux(auth, testHandler(t), okHandler("mcp"), nil, nil)
assert.Error(t, err)
}
// hyperguild#154: main wrapped buildMux's result in the metrics middleware
// before ListenAndServe, untested. buildMux now returns the handler main
// serves as-is, metrics included: a request through it is observed and
// exposed on its own GET /metrics.
func TestBuildMuxObservesRequestsAndServesMetrics(t *testing.T) {
r := testMux(t)
code, _ := do(t, r, "GET", "/pending", "Bearer "+routesTok)
require.Equal(t, http.StatusOK, code)
code, body := do(t, r, "GET", "/metrics", "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, `path="GET /pending"`)
}
// hyperguild#185: an authenticated request to an unregistered path reached
// the inner REST mux, which left Pattern empty, and the metrics label fell
// back to the raw URL path: one Prometheus series per unique path any token
// holder sends. It is now labelled with a fixed catch-all.
func TestBuildMuxLabelsAnUnregisteredPathWithAFixedLabel(t *testing.T) {
r := testMux(t)
code, _ := do(t, r, "GET", "/zqrandom-185-segment", "Bearer "+routesTok)
require.Equal(t, http.StatusNotFound, code)
_, body := do(t, r, "GET", "/metrics", "")
assert.NotContains(t, body, "zqrandom-185-segment", "the raw path became a metrics label")
assert.Contains(t, body, `path="unmatched"`)
}
@@ -1,9 +1,13 @@
package main package main
import ( import (
"context"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -63,3 +67,26 @@ func TestStartupSymlinkCheckRefusesAnUnlistableBrainRoot(t *testing.T) {
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) }) t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
assert.Error(t, startupSymlinkCheck(brain)) assert.Error(t, startupSymlinkCheck(brain))
} }
// hyperguild#179: a FIFO in the brain is refused at startup with a clear
// message, instead of hanging or exiting later in a reader.
func TestStartupSymlinkCheckRefusesABrainWithAFIFO(t *testing.T) {
brain := t.TempDir()
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "classification.yaml"), 0o644))
err := startupSymlinkCheck(brain)
require.Error(t, err)
assert.Contains(t, err.Error(), "classification.yaml")
}
// hyperguild#163: each alert source gets its own ntfy title.
func TestBuildNotifierUsesTheGivenTitle(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Get("Title") }))
defer srv.Close()
env := map[string]string{"BRAIN_NTFY_URL": srv.URL}
n := buildNotifier(func(k string) string { return env[k] }, "brain symlink tripwire")
require.NotNil(t, n)
require.NoError(t, n.Notify(context.Background(), "m"))
assert.Equal(t, "brain symlink tripwire", got)
assert.Nil(t, buildNotifier(func(string) string { return "" }, "x"), "no ntfy URL, no notifier")
}
+7 -7
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.26.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/term v0.28.0 // indirect golang.org/x/term v0.43.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
@@ -51,7 +51,7 @@ require (
) )
require ( require (
git.d-ma.be/mathias/mcp-chassis v0.2.0 git.d-ma.be/mathias/mcp-chassis v0.7.1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect github.com/goccy/go-json v0.10.3 // 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.32.0 // indirect golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.45.0
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.39.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
+16 -16
View File
@@ -1,5 +1,5 @@
git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo= git.d-ma.be/mathias/mcp-chassis v0.7.1 h1:4h8TOBHTA0L/no4IPcr4tLcQtmKUlhE2/cXyPdNFGmM=
git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8= git.d-ma.be/mathias/mcp-chassis v0.7.1/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
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=
+12
View File
@@ -83,6 +83,18 @@ func (f *frontmatter) get(key string) string {
return "" return ""
} }
// drop removes every `key: value` line for key (nothing else), so a
// machine-written key does not travel with a note into the wiki.
func (f *frontmatter) drop(key string) {
kept := f.lines[:0]
for _, l := range f.lines {
if l.key != key {
kept = append(kept, l)
}
}
f.lines = kept
}
// set overrides the value for an existing key in place, or appends a new // set overrides the value for an existing key in place, or appends a new
// `key: value` line when the key is absent. // `key: value` line when the key is absent.
func (f *frontmatter) set(key, value string) { func (f *frontmatter) set(key, value string) {
+16 -6
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/claim" "github.com/mathiasbq/hyperguild/ingestion/internal/claim"
"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"
@@ -227,14 +228,22 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
// The directory is created only once the note is known to be writable: // The directory is created only once the note is known to be writable:
// a refused write leaves nothing behind, not even an empty hall dir // a refused write leaves nothing behind, not even an empty hall dir
// (brain#21; provenance refusal joins the envelope refusal here). // (brain#21; provenance refusal joins the envelope refusal here).
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { // Created through the brain root, never through a symlinked wing or
return "", fmt.Errorf("create hall dir: %w", err) // hall directory (hyperguild#188).
rel, err := filepath.Rel(brainDir, dest)
if err != nil {
return "", fmt.Errorf("note path: %w", err)
} }
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil { rel = filepath.ToSlash(rel)
bfs, err := brainfs.Open(brainDir)
if err != nil {
return "", fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
if err := bfs.PutRegular(rel, []byte(final)); err != nil {
return "", fmt.Errorf("write: %w", err) return "", fmt.Errorf("write: %w", err)
} }
rel, _ := filepath.Rel(brainDir, dest) return rel, nil
return filepath.ToSlash(rel), nil
} }
// scalarValues flattens frontmatter fields to key -> scalar value for // scalarValues flattens frontmatter fields to key -> scalar value for
@@ -608,6 +617,7 @@ type promoteRequest struct {
Wing string `json:"wing"` Wing string `json:"wing"`
Hall string `json:"hall"` Hall string `json:"hall"`
Slug string `json:"slug,omitempty"` Slug string `json:"slug,omitempty"`
Queue string `json:"queue,omitempty"`
} }
// Promote handles POST /promote — move a raw/ note into the wiki. A bad // Promote handles POST /promote — move a raw/ note into the wiki. A bad
@@ -621,7 +631,7 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
// Field by field: this door carries no principal, so the promoted note is // Field by field: this door carries no principal, so the promoted note is
// stamped origin: unknown and the body cannot set provenance (#145). // stamped origin: unknown and the body cannot set provenance (#145).
rel, err := PromoteNote(h.brainDir, PromoteOptions{ rel, err := PromoteNote(h.brainDir, PromoteOptions{
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug, Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug, Queue: req.Queue,
}) })
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
@@ -0,0 +1,51 @@
package api
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeParked(t *testing.T, brainDir, name, content string) {
t.Helper()
dir := filepath.Join(brainDir, "raw", "needs-review")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
}
func TestListPending_ShowsParkedNotesWithTheirProposal(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "fresh.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nA note not yet processed.\n")
writeParked(t, dir, "unsure.md", "---\nproposed_wing: radar\nproposal_confidence: 0.55\nproposal_reason: \"below threshold\"\ncreated_at: 2026-10-19T08:00:00Z\n---\nBody of the unsure note.\n")
got, err := ListPending(dir)
require.NoError(t, err)
require.Len(t, got, 2)
assert.Equal(t, "unsure.md", got[0].Filename, "oldest first across both queues")
assert.Equal(t, "needs-review", got[0].Queue)
assert.Equal(t, "radar", got[0].ProposedWing)
assert.Equal(t, "below threshold", got[0].ProposalReason)
assert.InDelta(t, 0.55, got[0].ProposalConfidence, 1e-9)
assert.Contains(t, got[0].Excerpt, "Body of the unsure note")
assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter")
assert.Equal(t, "fresh.md", got[1].Filename)
assert.Equal(t, QueueInbox, got[1].Queue)
assert.Empty(t, got[1].ProposedWing)
}
func TestListPending_ParkedSymlinkIsNotListed(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(t.TempDir(), "secret.md")
require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "raw", "needs-review", "link.md")))
got, err := ListPending(dir)
require.NoError(t, err)
assert.Empty(t, got, "same symlink rule as the top level (#153)")
}
+159 -30
View File
@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -20,6 +21,15 @@ type PendingNote struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
Excerpt string `json:"excerpt"` Excerpt string `json:"excerpt"`
// Set only for a note parked in raw/needs-review/ by the wing resolver
// (hyperguild#205): it was not placed automatically, and the proposal says
// what the resolver would have done and why it did not. Approve by moving
// the note into raw/<wing>/ and the watcher ingests it there.
Queue string `json:"queue"`
ProposedWing string `json:"proposed_wing,omitempty"`
ProposalReason string `json:"proposal_reason,omitempty"`
ProposalConfidence float64 `json:"proposal_confidence,omitempty"`
} }
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when // datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
@@ -38,18 +48,65 @@ func ListPending(brainDir string) ([]PendingNote, error) {
return nil, fmt.Errorf("read raw dir: %w", err) return nil, fmt.Errorf("read raw dir: %w", err)
} }
defer func() { _ = root.Close() }() defer func() { _ = root.Close() }()
out, err := listNotes(root, QueueInbox)
if err != nil {
return nil, err
}
// raw/needs-review/ is read through its own root, so a symlinked directory
// or note there is refused exactly like at the top level (#153).
if sub, serr := root.OpenRoot("needs-review"); serr == nil {
defer func() { _ = sub.Close() }()
parked, perr := listNotes(sub, QueueNeedsReview)
if perr != nil {
return nil, perr
}
out = append(out, parked...)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
return out, nil
}
// listNotes lists the regular .md notes directly under root. queue labels the
// notes ("" for the top of raw/) and enables reading a wing proposal.
func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
entries, err := fs.ReadDir(root.FS(), ".") entries, err := fs.ReadDir(root.FS(), ".")
if err != nil { if err != nil {
return nil, fmt.Errorf("read raw dir: %w", err) 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-") { isMD := strings.HasSuffix(e.Name(), ".md")
// A parked pdf or txt is listed too (the watcher parks any supported
// file); at the top level only markdown ever was.
parkedOther := queue == QueueNeedsReview && (strings.HasSuffix(e.Name(), ".txt") || strings.HasSuffix(e.Name(), ".pdf"))
if !e.Type().IsRegular() || (!isMD && !parkedOther) || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
continue // directories, and symlinks whose target may be anywhere (#153) continue // directories, and symlinks whose target may be anywhere (#153)
} }
if !isMD {
info, ierr := root.Lstat(e.Name())
if ierr != nil {
continue
}
out = append(out, PendingNote{
Filename: e.Name(), CreatedAt: info.ModTime().UTC().Format(time.RFC3339), SizeBytes: info.Size(),
Excerpt: "(not markdown; the wing proposal is in log.md. Move the file into raw/<wing>/ to ingest it.)",
Queue: queue,
})
continue
}
raw, info, readErr := readRawNote(root, e.Name()) raw, info, readErr := readRawNote(root, e.Name())
if readErr != nil { if readErr != nil {
// Not skipped: a note that cannot be read (over the cap, say) must
// not silently vanish from the queue it is waiting in.
fi, ierr := root.Lstat(e.Name())
if ierr != nil {
continue
}
out = append(out, PendingNote{
Filename: e.Name(), CreatedAt: fi.ModTime().UTC().Format(time.RFC3339), SizeBytes: fi.Size(),
Excerpt: "(cannot be read: " + readErr.Error() + "; promote and reject cannot act on it: delete or move it by hand)", Queue: queue,
})
continue continue
} }
fm, body := parseFrontmatter(string(raw)) fm, body := parseFrontmatter(string(raw))
@@ -57,14 +114,20 @@ func ListPending(brainDir string) ([]PendingNote, error) {
if created == "" { if created == "" {
created = info.ModTime().UTC().Format(time.RFC3339) created = info.ModTime().UTC().Format(time.RFC3339)
} }
out = append(out, PendingNote{ n := PendingNote{
Filename: e.Name(), Filename: e.Name(),
CreatedAt: created, CreatedAt: created,
SizeBytes: info.Size(), SizeBytes: info.Size(),
Excerpt: excerpt(body, 200), Excerpt: excerpt(body, 200),
}) Queue: queue,
}
if queue == QueueNeedsReview {
n.ProposedWing = fm.get("proposed_wing")
n.ProposalReason = unquoteScalar(fm.get("proposal_reason"))
n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64)
}
out = append(out, n)
} }
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
return out, nil return out, nil
} }
@@ -74,6 +137,9 @@ type PromoteOptions struct {
Wing string Wing string
Hall string Hall string
Slug string // optional; defaults to Filename minus date prefix + .md Slug string // optional; defaults to Filename minus date prefix + .md
// Queue names which listing the note is in: QueueInbox or QueueNeedsReview.
// Empty is accepted only when the name exists in exactly one of them.
Queue string
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw // Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
// note's own frontmatter claims are not provenance (hyperguild#145). // note's own frontmatter claims are not provenance (hyperguild#145).
Origin string Origin string
@@ -96,21 +162,30 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
return "", fmt.Errorf("invalid filename %q", opts.Filename) return "", fmt.Errorf("invalid filename %q", opts.Filename)
} }
if !strings.HasSuffix(base, ".md") {
return "", fmt.Errorf("only a markdown note can be promoted; move %q into raw/<wing>/ to ingest it", base)
}
slug := opts.Slug slug := opts.Slug
if slug == "" { if slug == "" {
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "") slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
} }
// The retired layout's directory names are not wings: a note promoted into
// "sources" would land in wiki/sources/<hall>/, inside the retired dir.
switch brain.Sanitise(opts.Wing) {
case "sources", "concepts", "entities", "wiki":
return "", fmt.Errorf("%q is not a wing", opts.Wing)
}
// NotePath validates hall + wing + slug; do this before reading anything. // NotePath validates hall + wing + slug; do this before reading anything.
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug) dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
if err != nil { if err != nil {
return "", err return "", err
} }
src := filepath.Join(brainDir, "raw", base) raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
raw, err := readRawSource(brainDir, base)
if err != nil { if err != nil {
return "", err return "", err
} }
src := filepath.Join(brainDir, filepath.FromSlash(srcRel))
// Collision: never silently overwrite an existing promoted note. // Collision: never silently overwrite an existing promoted note.
if _, statErr := os.Stat(dest); statErr == nil { if _, statErr := os.Stat(dest); statErr == nil {
@@ -119,6 +194,11 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
} }
fm, body := parseFrontmatter(string(raw)) fm, body := parseFrontmatter(string(raw))
// The resolver's proposal described where the note was going to be placed;
// once a human has placed it, it is noise (and `wing` is set below).
for _, k := range []string{"proposed_wing", "proposal_confidence", "proposal_reason"} {
fm.drop(k)
}
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
fm.set("wing", brain.Sanitise(opts.Wing)) fm.set("wing", brain.Sanitise(opts.Wing))
fm.set("hall", opts.Hall) fm.set("hall", opts.Hall)
@@ -127,18 +207,18 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
} }
fm.set("promoted_at", now) fm.set("promoted_at", now)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { // Write-then-delete: the source survives any write failure. Both go
return "", fmt.Errorf("create wing dir: %w", err) // through the brain root (hyperguild#188).
}
// Write-then-delete: the source survives any write failure.
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy) promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil { // Created with O_EXCL: a note that appeared after the collision check
// above is never overwritten.
if err := createBrainFile(brainDir, dest, []byte(promoted)); err != nil {
return "", fmt.Errorf("write promoted note: %w", err) return "", fmt.Errorf("write promoted note: %w", err)
} }
if err := os.Remove(src); err != nil { if err := removeBrainFile(brainDir, src); err != nil {
return "", fmt.Errorf("promoted note written but source removal failed: %w", err) return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
} }
@@ -156,6 +236,7 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
type RejectOptions struct { type RejectOptions struct {
Filename string // basename in brain/raw/ Filename string // basename in brain/raw/
Reason string // optional Reason string // optional
Queue string // as PromoteOptions.Queue
} }
// RejectNote records a human decision not to promote a brain/raw/ note, // RejectNote records a human decision not to promote a brain/raw/ note,
@@ -178,11 +259,11 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
return "", fmt.Errorf("invalid filename %q", opts.Filename) return "", fmt.Errorf("invalid filename %q", opts.Filename)
} }
src := filepath.Join(brainDir, "raw", base) raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
raw, err := readRawSource(brainDir, base)
if err != nil { if err != nil {
return "", err return "", err
} }
src := filepath.Join(brainDir, filepath.FromSlash(srcRel))
dest := filepath.Join(brainDir, "raw", "rejected", base) dest := filepath.Join(brainDir, "raw", "rejected", base)
if _, statErr := os.Stat(dest); statErr == nil { if _, statErr := os.Stat(dest); statErr == nil {
@@ -190,20 +271,22 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel)) return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
} }
content := string(raw)
if strings.HasSuffix(base, ".md") {
fm, body := parseFrontmatter(string(raw)) fm, body := parseFrontmatter(string(raw))
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339)) fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
if opts.Reason != "" { if opts.Reason != "" {
fm.set("reject_reason", opts.Reason) fm.set("reject_reason", opts.Reason)
} }
content = fm.render() + body
} // a pdf or txt is moved byte for byte: prepending frontmatter would alter it
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { // Write-then-delete: the source survives any write failure. Both go
return "", fmt.Errorf("create rejected dir: %w", err) // through the brain root (hyperguild#188).
} if err := createBrainFile(brainDir, dest, []byte(content)); err != nil {
// Write-then-delete: the source survives any write failure.
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
return "", fmt.Errorf("write rejected note: %w", err) return "", fmt.Errorf("write rejected note: %w", err)
} }
if err := os.Remove(src); err != nil { if err := removeBrainFile(brainDir, src); err != nil {
return "", fmt.Errorf("rejected note written but source removal failed: %w", err) return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
} }
@@ -223,21 +306,67 @@ func excerpt(s string, n int) string {
// readRawSource reads a pending note for promote/reject through the raw root, // readRawSource reads a pending note for promote/reject through the raw root,
// refusing symlinks and a symlinked raw/ (hyperguild#153). // refusing symlinks and a symlinked raw/ (hyperguild#153).
func readRawSource(brainDir, base string) ([]byte, error) { func readRawSource(brainDir, base, queue string) ([]byte, string, error) {
if queue != "" && queue != QueueInbox && queue != QueueNeedsReview {
return nil, "", fmt.Errorf("unknown queue %q (want %q or %q)", queue, QueueInbox, QueueNeedsReview)
}
root, _, err := OpenRawRoot(brainDir) root, _, err := OpenRawRoot(brainDir)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("pending note %q does not exist in raw/", base) return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
} }
return nil, fmt.Errorf("read source: %w", err) return nil, "", fmt.Errorf("read source: %w", err)
} }
defer func() { _ = root.Close() }() defer func() { _ = root.Close() }()
raw, _, err := readRawNote(root, base)
if err != nil { var topRaw, parkedRaw []byte
if errors.Is(err, os.ErrNotExist) { var topErr, parkedErr error
return nil, fmt.Errorf("pending note %q does not exist in raw/", base) if queue != QueueNeedsReview {
topRaw, _, topErr = readRawNote(root, base)
} }
return nil, fmt.Errorf("read source: %w", err) if queue != QueueInbox {
// The sub-root refuses a symlinked directory like OpenRawRoot does.
if sub, serr := root.OpenRoot(parkedDir); serr != nil {
parkedErr = serr
} else {
parkedRaw, _, parkedErr = readRawNote(sub, base)
_ = sub.Close()
} }
return raw, nil }
haveTop := queue != QueueNeedsReview && topErr == nil
haveParked := queue != QueueInbox && parkedErr == nil
switch {
case haveTop && haveParked:
return nil, "", fmt.Errorf("%q is in both raw/ and raw/%s/: name the queue (%q or %q)", base, parkedDir, QueueInbox, QueueNeedsReview)
case haveTop:
return topRaw, "raw/" + base, nil
case haveParked:
return parkedRaw, "raw/" + parkedDir + "/" + base, nil
}
for _, e := range []error{topErr, parkedErr} {
if e != nil && !errors.Is(e, os.ErrNotExist) {
return nil, "", fmt.Errorf("read source: %w", e)
}
}
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
}
// parkedDir is where the watcher parks notes it could not place.
const parkedDir = "needs-review"
// The two review queues brain_pending lists. promote and reject take one of
// them (or neither, when the name is unambiguous).
const (
QueueInbox = "inbox" // raw/<name>
QueueNeedsReview = "needs-review" // raw/needs-review/<name>
)
// unquoteScalar undoes the Go-style double quoting the watcher writes around a
// free-text value (strconv.Quote keeps it on one line); a bare value is
// returned as is.
func unquoteScalar(v string) string {
if u, err := strconv.Unquote(v); err == nil {
return u
}
return v
} }
@@ -0,0 +1,210 @@
package api
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// brain_pending lists a top-level note and a parked note of the same name as two
// entries with the same filename. Acting on "a.md" must not silently pick one.
func TestPromoteNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
writeParked(t, dir, "a.md", parkedNote)
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts"})
require.Error(t, err)
assert.Contains(t, err.Error(), "both")
assert.Contains(t, err.Error(), "queue")
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/a.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was promoted")
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
require.NoError(t, err)
got, _ := os.ReadFile(filepath.Join(dir, rel))
assert.Contains(t, string(got), "Body of the parked note.")
_, statErr = os.Stat(filepath.Join(dir, "raw", "a.md"))
assert.NoError(t, statErr, "the top-level note of the same name is untouched")
}
func TestPromoteNote_QueueInboxSelectsTheTopLevelNote(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
writeParked(t, dir, "a.md", parkedNote)
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueInbox})
require.NoError(t, err)
got, _ := os.ReadFile(filepath.Join(dir, rel))
assert.Contains(t, string(got), "Top-level body.")
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "a.md"))
assert.NoError(t, statErr, "the parked note is untouched")
}
func TestPromoteNote_ANamedQueueThatDoesNotHoldTheNoteIsAnError(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "a.md", "top")
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
assert.Error(t, err)
_, err = PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: "elsewhere"})
assert.Error(t, err, "an unknown queue name is refused")
}
func TestRejectNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "a.md", "top")
writeParked(t, dir, "a.md", parkedNote)
_, err := RejectNote(dir, RejectOptions{Filename: "a.md"})
require.Error(t, err)
_, err = RejectNote(dir, RejectOptions{Filename: "a.md", Queue: QueueNeedsReview})
require.NoError(t, err)
_, statErr := os.Stat(filepath.Join(dir, "raw", "a.md"))
assert.NoError(t, statErr, "the top-level note is untouched")
}
func TestPromoteNote_NeverIntoARetiredLayoutName(t *testing.T) {
for _, w := range []string{"sources", "concepts", "entities", "wiki"} {
t.Run(w, func(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "a.md", "top")
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"})
assert.Error(t, err)
_, statErr := os.Stat(filepath.Join(dir, "wiki", w))
assert.ErrorIs(t, statErr, os.ErrNotExist)
})
}
}
func TestListPending_LabelsEveryEntryWithItsQueue(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "fresh.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nx\n")
writeParked(t, dir, "p.md", parkedNote)
got, err := ListPending(dir)
require.NoError(t, err)
require.Len(t, got, 2)
queues := map[string]string{}
for _, n := range got {
queues[n.Filename] = n.Queue
}
assert.Equal(t, QueueInbox, queues["fresh.md"])
assert.Equal(t, QueueNeedsReview, queues["p.md"])
}
// Parked notes that are not markdown (a pdf, a txt) were invisible: the only
// trace was a log.md line.
func TestListPending_ShowsParkedNonMarkdownFiles(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF-1.4 binary"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "plain.txt"), []byte("some text"), 0o644))
got, err := ListPending(dir)
require.NoError(t, err)
names := map[string]PendingNote{}
for _, n := range got {
names[n.Filename] = n
}
require.Contains(t, names, "paper.pdf")
require.Contains(t, names, "plain.txt")
assert.Equal(t, QueueNeedsReview, names["paper.pdf"].Queue)
assert.Contains(t, names["paper.pdf"].Excerpt, "proposal", "points the reader at where the proposal is")
}
func TestPromoteNote_OnlyMarkdownCanBePromoted(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF binary"), 0o644))
_, err := PromoteNote(dir, PromoteOptions{Filename: "paper.pdf", Wing: "homelab", Hall: "facts"})
require.Error(t, err)
assert.Contains(t, err.Error(), "markdown")
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf"))
assert.NoError(t, statErr, "refused, untouched")
}
// brain.Sanitise normalises case, trailing dots and separators; the reserved-name
// check must look at the normalised name or "Sources" and "sources." walk past it.
func TestPromoteNote_ReservedWingNamesAreRefusedInEveryDisguise(t *testing.T) {
for _, w := range []string{"Sources", "sources.", "SOURCES ", "Entities/", "concepts\\", " wiki "} {
t.Run(w, func(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "a.md", "top")
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"})
assert.Error(t, err)
_, statErr := os.Stat(filepath.Join(dir, "raw", "a.md"))
assert.NoError(t, statErr, "refused, untouched")
})
}
}
// Reject moves the bytes; it must not prepend frontmatter to a pdf or txt.
func TestRejectNote_AParkedNonMarkdownFileIsMovedUntouched(t *testing.T) {
dir := t.TempDir()
pdf := "%PDF-1.4\r\n\x00\xff binary \r\n"
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte(pdf), 0o644))
rel, err := RejectNote(dir, RejectOptions{Filename: "paper.pdf", Reason: "not relevant"})
require.NoError(t, err)
got, err := os.ReadFile(filepath.Join(dir, rel))
require.NoError(t, err)
assert.Equal(t, pdf, string(got), "byte for byte")
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf"))
assert.ErrorIs(t, statErr, os.ErrNotExist)
}
func TestReadRawNote_IsBoundedByTheCap(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "big.md"), []byte(strings.Repeat("x", 200)), 0o644))
root, err := os.OpenRoot(dir)
require.NoError(t, err)
defer func() { _ = root.Close() }()
prev := maxRawNoteBytes
maxRawNoteBytes = 100
t.Cleanup(func() { maxRawNoteBytes = prev })
_, _, err = readRawNote(root, "big.md")
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds")
}
type endless struct{ n int64 }
func (e *endless) Read(p []byte) (int, error) {
if e.n > 8<<20 { // a regression fails the test instead of exhausting memory
return 0, errors.New("endless reader drained")
}
e.n += int64(len(p))
return len(p), nil
}
func TestReadCapped_StopsAtTheCap(t *testing.T) {
e := &endless{}
_, err := readCapped(e, 1000)
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds")
assert.LessOrEqual(t, e.n, int64(1000+1+32*1024), "pulled far more than the cap: %d", e.n)
}
// An .md over the read cap used to vanish from the listing (readRawNote failed
// and the entry was skipped). It must stay visible, with the reason.
func TestListPending_AnUnreadableNoteIsListedWithTheReason(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "huge.md"), []byte(strings.Repeat("x", 200)), 0o644))
prev := maxRawNoteBytes
maxRawNoteBytes = 100
t.Cleanup(func() { maxRawNoteBytes = prev })
got, err := ListPending(dir)
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "huge.md", got[0].Filename)
assert.Contains(t, got[0].Excerpt, "cannot be read")
assert.Contains(t, got[0].Excerpt, "exceeds")
}
@@ -0,0 +1,87 @@
package api_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// hyperguild#188: promote and reject wrote the destination with
// os.MkdirAll + os.WriteFile by path, so a symlinked wing (promote) or a
// symlinked raw/rejected (reject) redirected the note; the raw source was
// then removed.
func TestPromoteAndRejectRefuseASymlinkedDestinationDirectory(t *testing.T) {
brain := t.TempDir()
outside := t.TempDir()
raw := filepath.Join(brain, "raw")
require.NoError(t, os.MkdirAll(raw, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-10-05-n.md"), []byte("---\ntitle: n\n---\nbody\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
require.NoError(t, os.Symlink(outside, filepath.Join(brain, "wiki", "lw")))
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "rejected")))
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "lw", Hall: "facts"})
assert.Error(t, err)
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"})
assert.Error(t, err)
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents, "a note was written outside the brain")
_, err = os.Stat(filepath.Join(raw, "2026-10-05-n.md"))
assert.NoError(t, err, "the raw source is kept when the write is refused")
require.NoError(t, os.Remove(filepath.Join(raw, "rejected")))
_, err = api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "w", Hall: "facts"})
require.NoError(t, err, "positive control: promote into a real wing")
}
// StampProvenanceFile read and rewrote the note by path (provenance.ApplyFile).
func TestStampProvenanceFileRefusesANoteBehindASymlinkedDirectory(t *testing.T) {
brain := t.TempDir()
victim := filepath.Join(brain, "raw", "facts", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("---\ntitle: v\n---\nuntouched\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(brain, "wiki", "w", "alias")))
assert.Error(t, api.StampProvenanceFile(brain, "wiki/w/alias/v.md", "static", "x"))
got, _ := os.ReadFile(victim)
assert.Equal(t, "---\ntitle: v\n---\nuntouched\n", string(got))
real := filepath.Join(brain, "wiki", "w", "facts", "r.md")
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
require.NoError(t, os.WriteFile(real, []byte("---\ntitle: r\n---\nbody\n"), 0o644))
require.NoError(t, api.StampProvenanceFile(brain, "wiki/w/facts/r.md", "static", "x"), "positive control")
got, _ = os.ReadFile(real)
assert.Contains(t, string(got), "written_by: x")
}
// The same through an in-brain symlinked wing and raw/rejected, which
// os.Root alone would follow.
func TestPromoteAndRejectRefuseAnInBrainSymlinkedDestinationDirectory(t *testing.T) {
brain := t.TempDir()
raw := filepath.Join(brain, "raw")
require.NoError(t, os.MkdirAll(raw, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-10-05-n.md"), []byte("---\ntitle: n\n---\nbody\n"), 0o644))
target := filepath.Join(brain, "archive")
require.NoError(t, os.MkdirAll(target, 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "archive"), filepath.Join(brain, "wiki", "lw")))
require.NoError(t, os.Symlink(filepath.Join("..", "archive"), filepath.Join(raw, "rejected")))
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "lw", Hall: "facts"})
assert.Error(t, err)
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"})
assert.Error(t, err)
ents, _ := os.ReadDir(target)
assert.Empty(t, ents, "a note was written at the in-brain link's target")
_, err = os.Stat(filepath.Join(raw, "2026-10-05-n.md"))
assert.NoError(t, err, "the raw source is kept")
require.NoError(t, os.Remove(filepath.Join(raw, "rejected")))
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"})
require.NoError(t, err, "positive control: reject into a real raw/rejected")
}
@@ -0,0 +1,62 @@
package api
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const parkedNote = "---\nproposed_wing: radar\nproposal_confidence: 0.40\nproposal_reason: \"text matches the client-content marker\"\ncreated_at: 2026-10-19T08:00:00Z\n---\nBody of the parked note.\n"
// brain_pending lists a parked note under its bare filename, so promote and
// reject must be able to act on exactly that name (#208: they could not).
func TestPromoteNote_ActsOnANoteParkedInNeedsReview(t *testing.T) {
dir := t.TempDir()
writeParked(t, dir, "unsure.md", parkedNote)
rel, err := PromoteNote(dir, PromoteOptions{Filename: "unsure.md", Wing: "homelab", Hall: "facts"})
require.NoError(t, err)
assert.Equal(t, "wiki/homelab/facts/unsure.md", rel)
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/unsure.md"))
require.NoError(t, err)
assert.Contains(t, string(got), "Body of the parked note.")
assert.Contains(t, string(got), "wing: homelab")
assert.NotContains(t, string(got), "proposed_wing", "the resolver's proposal is not part of the promoted note")
assert.NotContains(t, string(got), "proposal_reason")
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "unsure.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist, "the parked note is consumed")
pending, err := ListPending(dir)
require.NoError(t, err)
assert.Empty(t, pending)
}
func TestRejectNote_ActsOnANoteParkedInNeedsReview(t *testing.T) {
dir := t.TempDir()
writeParked(t, dir, "unsure.md", parkedNote)
rel, err := RejectNote(dir, RejectOptions{Filename: "unsure.md", Reason: "not worth keeping"})
require.NoError(t, err)
assert.Equal(t, "raw/rejected/unsure.md", rel)
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "unsure.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist)
pending, _ := ListPending(dir)
assert.Empty(t, pending, "a rejected note is not re-listed")
}
func TestPromoteNote_ASymlinkedNeedsReviewIsNotRead(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.md"), []byte("outside"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "raw", "needs-review")))
_, err := PromoteNote(dir, PromoteOptions{Filename: "secret.md", Wing: "homelab", Hall: "facts"})
assert.Error(t, err)
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/secret.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist)
}
+57 -3
View File
@@ -1,6 +1,10 @@
package api package api
import ( import (
"fmt"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance" "github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
) )
@@ -11,11 +15,61 @@ func StampProvenance(content, origin, writtenBy string) (string, error) {
return provenance.Apply(content, provenance.Stamp{Origin: origin, WrittenBy: writtenBy}) return provenance.Apply(content, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
} }
// StampProvenanceFile applies StampProvenance to a brain-relative note path. // StampProvenanceFile applies StampProvenance to a brain-relative note path,
// read and rewritten through the brain root (hyperguild#158).
func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error { func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error {
_, abs, err := resolveWithin(brainDir, relPath) rel, _, err := resolveWithin(brainDir, relPath)
if err != nil { if err != nil {
return err return err
} }
return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy}) bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
b, err := bfs.ReadRegular(rel)
if err != nil {
return fmt.Errorf("read for provenance: %w", err)
}
out, err := provenance.Apply(string(b), provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
if err != nil {
return err
}
if out == string(b) {
return nil
}
if err := bfs.WriteRegular(rel, []byte(out)); err != nil {
return fmt.Errorf("write provenance: %w", err)
}
return nil
}
// createBrainFile creates the file at abs (inside brainDir) through the
// brain root and fails if anything is already there.
func createBrainFile(brainDir, abs string, data []byte) error {
rel, err := filepath.Rel(brainDir, abs)
if err != nil {
return err
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
return bfs.CreateRegular(filepath.ToSlash(rel), data)
}
// removeBrainFile removes the file at abs (inside brainDir) through the
// brain root.
func removeBrainFile(brainDir, abs string) error {
rel, err := filepath.Rel(brainDir, abs)
if err != nil {
return err
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
return bfs.Remove(filepath.ToSlash(rel))
} }
+19 -2
View File
@@ -38,6 +38,10 @@ func OpenRawRoot(brainDir string) (*os.Root, string, error) {
return r, p, nil return r, p, nil
} }
// maxRawNoteBytes bounds what promote, reject and the pending listing read from
// one raw/ entry. A variable so a test can lower it.
var maxRawNoteBytes int64 = 64 << 20
// readRawNote reads one raw/ entry by basename through the root, refusing // readRawNote reads one raw/ entry by basename through the root, refusing
// anything that is not a regular file. // anything that is not a regular file.
// //
@@ -59,9 +63,22 @@ func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
return nil, nil, err return nil, nil, err
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
b, err := io.ReadAll(f) b, err := readCapped(f, maxRawNoteBytes)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, fmt.Errorf("%s: %w", base, err)
} }
return b, fi, nil return b, fi, nil
} }
// readCapped reads r but never more than max+1 bytes, so an oversize or growing
// file costs at most that much memory.
func readCapped(r io.Reader, max int64) ([]byte, error) {
b, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, err
}
if int64(len(b)) > max {
return nil, fmt.Errorf("note exceeds %d bytes", max)
}
return b, nil
}
@@ -23,6 +23,13 @@ func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
_, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md") _, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md")
assert.Error(t, err) assert.Error(t, err)
assert.NotContains(t, body, brainfstest.Sentinel) assert.NotContains(t, body, brainfstest.Sentinel)
// A link to a file elsewhere in the brain: os.Root allows it, only the
// regular-file check refuses it (hyperguild#171).
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
_, body, _, err = api.ReadNote(brain, "wiki/w/facts/inlink.md")
assert.Error(t, err)
assert.NotContains(t, body, brainfstest.Sentinel)
p := filepath.Join(facts, "r.md") p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644)) require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
@@ -39,5 +46,7 @@ func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
return s return s
} }
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, read) brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, read)
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
brainfstest.AssertNoLeakFrom(t, p, "---\nx: y\n---\ninside", inBrain, 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() }) brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
} }
+18 -6
View File
@@ -3,15 +3,18 @@ package api
import ( import (
"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"
"time" "time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/claim" "github.com/mathiasbq/hyperguild/ingestion/internal/claim"
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
) )
// ContentHash returns the lowercase hex sha256 of b. It is the note's // ContentHash returns the lowercase hex sha256 of b. It is the note's
@@ -91,14 +94,23 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
} }
rel, abs, err := resolveWithin(brainDir, rel) // The path is caller-supplied (brain_update): resolve it as brain_edit
// does, knowledge/ and wiki/ notes only, and read and rewrite it through
// the brain root, never through a symlink (hyperguild#169).
t, err := notes.Resolve(brainDir, rel, notes.ScopeNotes)
if err != nil { if err != nil {
return "", "", "", err return "", "", "", err
} }
rel = t.Rel
prior, err := os.ReadFile(abs) bfs, err := brainfs.Open(brainDir)
if err != nil { if err != nil {
if os.IsNotExist(err) { return "", "", "", fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
prior, err := bfs.ReadRegular(rel)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel) return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel)
} }
return "", "", "", fmt.Errorf("read target: %w", err) return "", "", "", fmt.Errorf("read target: %w", err)
@@ -164,7 +176,7 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
return "", "", "", err return "", "", "", err
} }
out := []byte(stamped) out := []byte(stamped)
if err := os.WriteFile(abs, out, 0o644); err != nil { if err := bfs.WriteRegular(rel, out); err != nil {
return "", "", "", fmt.Errorf("write: %w", err) return "", "", "", fmt.Errorf("write: %w", err)
} }
return rel, ContentHash(out), priorHash, nil return rel, ContentHash(out), priorHash, nil
@@ -0,0 +1,36 @@
package api_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
)
// hyperguild#173: a directory symlink inside wiki/ that points elsewhere in
// the brain redirected brain_update's write (os.Root follows in-root
// intermediate symlinks). Every directory above the note must be real.
func TestUpdateNoteRefusesANoteBehindASymlinkedDirectory(t *testing.T) {
brain := t.TempDir()
victim := filepath.Join(brain, "raw", "facts", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("---\ntitle: v\n---\nuntouched\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(brain, "wiki", "w", "alias")))
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/alias/v.md", Content: "replaced"})
assert.ErrorIs(t, err, brainfs.ErrNotRegular)
got, _ := os.ReadFile(victim)
assert.Contains(t, string(got), "untouched")
real := filepath.Join(brain, "wiki", "w", "facts", "r.md")
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
require.NoError(t, os.WriteFile(real, []byte("---\ntitle: r\n---\nold\n"), 0o644))
_, _, _, err = api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/r.md", Content: "new"})
require.NoError(t, err, "positive control: a note in a real directory is updated")
}
+112
View File
@@ -0,0 +1,112 @@
package api_test
import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// brain_update passed the caller's path through resolveWithin, a lexical
// containment check only: any file in the brain could be superseded
// (classification.yaml, raw/, sessions/), and a final symlink was written
// through. It now resolves like brain_edit: knowledge/ and wiki/ notes only,
// read and rewritten through the brain root.
func TestUpdateNoteRefusesPathsOutsideKnowledgeAndWikiNotes(t *testing.T) {
brain := t.TempDir()
files := map[string]string{
"classification.yaml": "levels: {}\n",
"raw/r.md": "raw\n",
"sessions/s.jsonl": "{}\n",
"wiki/w/facts/ok.md": "---\ntitle: ok\n---\nold\n",
"knowledge/k.md": "---\ntitle: k\n---\nold\n",
"wiki/w/facts/x.txt": "txt\n",
}
for rel, body := range files {
p := filepath.Join(brain, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
require.NoError(t, os.WriteFile(p, []byte(body), 0o644))
}
for _, rel := range []string{"classification.yaml", "raw/r.md", "sessions/s.jsonl", "wiki/w/facts/x.txt", "wiki/w/../../raw/r.md"} {
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: rel, Content: "replaced"})
assert.Error(t, err, rel)
}
for rel, body := range files {
if rel == "wiki/w/facts/ok.md" || rel == "knowledge/k.md" {
continue
}
got, err := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel)))
require.NoError(t, err)
assert.Equal(t, body, string(got), rel)
}
for _, rel := range []string{"wiki/w/facts/ok.md", "knowledge/k.md"} {
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: rel, Content: "new body"})
require.NoError(t, err, "positive control: %s", rel)
got, _ := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel)))
assert.Contains(t, string(got), "new body")
}
}
func TestUpdateNoteDoesNotWriteThroughASymlinkedNote(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
victim := filepath.Join(brain, "raw", "victim.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), filepath.Join(facts, "l.md")))
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/l.md", Content: "replaced"})
assert.Error(t, err)
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched\n", string(got))
}
// The note is swapped between a regular file and an in-brain symlink to a
// raw/ file while UpdateNote runs: the read and the write each refuse a
// symlink, so the raw/ file is never rewritten.
func TestUpdateNoteNeverRewritesASymlinkSwappedInForTheNote(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
victim := filepath.Join(brain, "raw", "victim.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
note := filepath.Join(facts, "n.md")
body := "---\ntitle: n\n---\nold\n"
require.NoError(t, os.WriteFile(note, []byte(body), 0o644))
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
tmp := note + ".swap"
for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp)
if i%2 == 0 {
_ = os.WriteFile(tmp, []byte(body), 0o644)
} else {
_ = os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), tmp)
}
_ = os.Rename(tmp, note)
}
}()
ok := 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
if _, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/n.md", Content: "new"}); err == nil {
ok++
}
}
stop.Store(true)
wg.Wait()
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched\n", string(got))
assert.Positive(t, ok, "positive control: some updates reach the regular note")
}
@@ -0,0 +1,34 @@
package api_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// hyperguild#178: an unreadable directory under wiki/ made the link check
// fail and brain_write refuse the note, losing the learning. The note is now
// written with its links as given.
func TestWriteNoteKeepsTheLearningWhenTheLinkCheckCannotIndex(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "w", "facts", "real.md"), []byte("x"), 0o644))
locked := filepath.Join(brain, "wiki", "other", "locked")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
rel, err := api.WriteNote(brain, api.WriteNoteOptions{Wing: "w", Hall: "facts", Filename: "n", Content: "learning [[real]] and [[gone]]"})
require.NoError(t, err)
got, err := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel)))
require.NoError(t, err)
assert.Contains(t, string(got), "[[gone]]", "links kept as given when the index is partial")
}
@@ -0,0 +1,43 @@
package api_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// hyperguild#188: brain_write created a new note with os.MkdirAll +
// os.WriteFile by path, so a symlinked wing directory redirected the note
// elsewhere in the brain or outside it.
func TestWriteNoteRefusesASymlinkedWing(t *testing.T) {
for name, target := range map[string]func(t *testing.T, brain string) string{
"in-brain": func(t *testing.T, brain string) string {
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
return filepath.Join("..", "raw")
},
"outside": func(t *testing.T, _ string) string { return t.TempDir() },
} {
t.Run(name, func(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
link := target(t, brain)
require.NoError(t, os.Symlink(link, filepath.Join(brain, "wiki", "lw")))
_, err := api.WriteNote(brain, api.WriteNoteOptions{Wing: "lw", Hall: "facts", Filename: "n", Content: "learning"})
assert.Error(t, err)
resolved := link
if !filepath.IsAbs(resolved) {
resolved = filepath.Join(brain, "wiki", link)
}
_, serr := os.Stat(filepath.Join(resolved, "facts"))
assert.True(t, os.IsNotExist(serr), "a hall was created at the link's target")
_, err = api.WriteNote(brain, api.WriteNoteOptions{Wing: "w", Hall: "facts", Filename: "n", Content: "learning"})
require.NoError(t, err, "positive control: a real wing is written")
})
}
}
+20 -1
View File
@@ -13,9 +13,14 @@ import (
type NtfyNotifier struct { type NtfyNotifier struct {
topicURL string topicURL string
token string token string
title string
http *http.Client http *http.Client
} }
// defaultNtfyTitle is the title of a notifier built without WithTitle: the
// capture audit's degraded-state alert, its first user.
const defaultNtfyTitle = "brain-capture audit degraded"
// NewNtfyNotifier constructs a notifier for the given ntfy topic URL // NewNtfyNotifier constructs a notifier for the given ntfy topic URL
// (e.g. https://ntfy.sh/my-topic). token is an optional bearer for // (e.g. https://ntfy.sh/my-topic). token is an optional bearer for
// protected ntfy instances; it is held here and only sent in the // protected ntfy instances; it is held here and only sent in the
@@ -27,17 +32,31 @@ func NewNtfyNotifier(topicURL, token string) *NtfyNotifier {
return &NtfyNotifier{ return &NtfyNotifier{
topicURL: strings.TrimRight(topicURL, "/"), topicURL: strings.TrimRight(topicURL, "/"),
token: token, token: token,
title: defaultNtfyTitle,
http: &http.Client{Timeout: 10 * time.Second}, http: &http.Client{Timeout: 10 * time.Second},
} }
} }
// WithTitle returns a copy that sends title as the ntfy Title header, so each
// alert source (the capture audit, the symlink tripwire, governance edits)
// is recognisable on the phone (hyperguild#163). A nil notifier (ntfy not
// configured) stays nil.
func (n *NtfyNotifier) WithTitle(title string) *NtfyNotifier {
if n == nil {
return nil
}
c := *n
c.title = title
return &c
}
// Notify posts a message to the ntfy topic. // Notify posts a message to the ntfy topic.
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error { func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Title", "brain-capture audit degraded") req.Header.Set("Title", n.title)
req.Header.Set("Priority", "high") req.Header.Set("Priority", "high")
req.Header.Set("Tags", "warning,brain") req.Header.Set("Tags", "warning,brain")
if n.token != "" { if n.token != "" {
@@ -0,0 +1,29 @@
package audit
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#163: every alert went out titled "brain-capture audit
// degraded", including the symlink tripwire's and governance alerts.
func TestNtfyNotifierSendsItsOwnTitle(t *testing.T) {
var got []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.Header.Get("Title"))
}))
defer srv.Close()
base := NewNtfyNotifier(srv.URL, "")
require.NoError(t, base.Notify(context.Background(), "m"))
require.NoError(t, base.WithTitle("brain symlink tripwire").Notify(context.Background(), "m"))
require.NoError(t, base.Notify(context.Background(), "m"))
assert.Equal(t, []string{"brain-capture audit degraded", "brain symlink tripwire", "brain-capture audit degraded"}, got,
"WithTitle returns a copy; the base keeps its default title")
var nilN *NtfyNotifier
assert.Nil(t, nilN.WithTitle("x"), "nil (ntfy not configured) stays nil")
}
@@ -0,0 +1,125 @@
package brain_test
import (
"fmt"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
)
// hyperguild#174 / #157 slice 5b: DetectTunnels walked wiki/ and opened
// every note with a blocking os.Open, so a FIFO named *.md anywhere under
// wiki/ hung every brain_write and brain_update (wikiUpkeep -> AutoTunnel),
// and a symlinked note's title became a tunnel candidate.
func TestDetectTunnelsReturnsPromptlyWithAFIFOUnderWikiWithAndWithoutAWriter(t *testing.T) {
for _, withWriter := range []bool{false, true} {
t.Run(fmt.Sprintf("writer=%v", withWriter), func(t *testing.T) {
b := t.TempDir()
facts := filepath.Join(b, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644))
fifo := filepath.Join(facts, "f.md")
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
if withWriter {
// A writer held open: a reader that opens the FIFO gets no
// EOF and parks, unless the FIFO is never opened.
w, err := os.OpenFile(fifo, os.O_RDWR, 0)
require.NoError(t, err)
t.Cleanup(func() { _ = w.Close() })
}
done := make(chan []brain.TunnelCandidate, 1)
go func() {
cs, _ := brain.DetectTunnels(b, "text mentioning zqinside phrase here")
done <- cs
}()
select {
case cs := <-done:
assert.Contains(t, fmt.Sprintf("%+v", cs), "Zqinside Phrase", "positive control")
case <-time.After(3 * time.Second):
if r, err := os.OpenFile(fifo, os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
_ = r.Close()
}
t.Fatal("DetectTunnels blocked on a FIFO")
}
})
}
}
func TestDetectTunnelsRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
b := t.TempDir()
facts := filepath.Join(b, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
// The static note's title differs from the raced note's, so the
// harness's "inside was read" floor can only be met by reading r.md.
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: Zqstatic Words\n---\n"), 0o644))
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "out.md")))
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(b, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "in.md")))
content := "zqstatic words, zqinside phrase and " + brainfstest.Sentinel
cs, err := brain.DetectTunnels(b, content)
require.NoError(t, err)
got := fmt.Sprintf("%+v", cs)
assert.Contains(t, got, "Zqstatic Words", "positive control")
assert.NotContains(t, got, brainfstest.Sentinel)
p := filepath.Join(facts, "r.md")
read := func() string {
cs, _ := brain.DetectTunnels(b, content)
return fmt.Sprintf("%+v", cs)
}
brainfstest.AssertNoLeak(t, p, "---\ntitle: Zqinside Phrase\n---\n", 1500*time.Millisecond, read)
brainfstest.AssertNoLeakFrom(t, p, "---\ntitle: Zqinside Phrase\n---\n", inBrain, 1500*time.Millisecond, read)
}
// A wiki/ that is not a real directory yields no candidates and no error:
// AutoTunnel runs inside brain_write.
func TestDetectTunnelsSkipsAWikiThatIsNotARealDirectory(t *testing.T) {
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "raw", "w", "facts", "r.md"), []byte("---\ntitle: Zqrawonly Phrase\n---\n"), 0o644))
require.NoError(t, os.Symlink("raw", filepath.Join(b, "wiki")))
cs, err := brain.DetectTunnels(b, "zqrawonly phrase")
require.NoError(t, err)
assert.Empty(t, cs)
}
// An unreadable directory under wiki/ is a walk error and DetectTunnels
// reports it, as filepath.WalkDir did; it is not silently dropped.
func TestDetectTunnelsReportsAnUnreadableDirectoryUnderWiki(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644))
locked := filepath.Join(b, "wiki", "other", "locked")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
_, err := brain.DetectTunnels(b, "zqinside phrase")
assert.ErrorIs(t, err, os.ErrPermission)
}
// _index.md files are generated, not notes: their title never becomes a
// candidate.
func TestDetectTunnelsSkipsIndexFiles(t *testing.T) {
b := t.TempDir()
w := filepath.Join(b, "wiki", "w")
require.NoError(t, os.MkdirAll(filepath.Join(w, "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(w, "_index.md"), []byte("---\ntitle: Zqindex Words\n---\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(w, "facts", "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644))
cs, err := brain.DetectTunnels(b, "zqindex words and zqinside phrase")
require.NoError(t, err)
got := fmt.Sprintf("%+v", cs)
assert.Contains(t, got, "Zqinside Phrase", "positive control")
assert.NotContains(t, got, "Zqindex")
}
+58 -47
View File
@@ -2,12 +2,16 @@ package brain
import ( import (
"bufio" "bufio"
"bytes"
"errors"
"fmt" "fmt"
"os" "io"
"path/filepath" "io/fs"
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
) )
// noteEntry is one row in a Wing _index.md. // noteEntry is one row in a Wing _index.md.
@@ -26,14 +30,18 @@ func BuildWingIndex(brainDir, wing string) error {
if w == "" { if w == "" {
return fmt.Errorf("invalid wing %q", wing) return fmt.Errorf("invalid wing %q", wing)
} }
wingDir := filepath.Join(brainDir, "wiki", w) // Listed and read through the brain root (hyperguild#157): a wing or
if _, err := os.Stat(wingDir); os.IsNotExist(err) { // hall that is a symlink is not followed, and notes are regular files.
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
if _, err := bfs.Lstat("wiki/" + w); errors.Is(err, fs.ErrNotExist) {
return nil return nil
} else if err != nil {
return fmt.Errorf("stat wing: %w", err)
} }
entries, err := collectWingEntries(wingDir) entries, err := collectWingEntries(bfs, "wiki/"+w)
if err != nil { if err != nil {
return err return err
} }
@@ -52,74 +60,85 @@ func BuildWingIndex(brainDir, wing string) error {
fmt.Fprintf(&b, "| %s | [%s](%s/%s.md) | %s |\n", e.Hall, e.Title, e.Hall, e.Slug, e.Created) fmt.Fprintf(&b, "| %s | [%s](%s/%s.md) | %s |\n", e.Hall, e.Title, e.Hall, e.Slug, e.Created)
} }
dest := filepath.Join(wingDir, "_index.md") // Written through the brain root, never through a symlinked _index.md
return os.WriteFile(dest, []byte(b.String()), 0o644) // (hyperguild#158).
return bfs.PutRegular("wiki/"+w+"/_index.md", []byte(b.String()))
} }
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/. // BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
func BuildAllWingIndexes(brainDir string) error { func BuildAllWingIndexes(brainDir string) error {
wikiDir := filepath.Join(brainDir, "wiki") bfs, err := brainfs.Open(brainDir)
ents, err := os.ReadDir(wikiDir) if err != nil {
if os.IsNotExist(err) { return fmt.Errorf("open brain: %w", err)
}
wings, err := bfs.ReadDirDirs("wiki")
_ = bfs.Close()
if errors.Is(err, fs.ErrNotExist) {
return nil return nil
} }
if err != nil { if err != nil {
return fmt.Errorf("read wiki: %w", err) return fmt.Errorf("read wiki: %w", err)
} }
for _, e := range ents { for _, w := range wings {
if !e.IsDir() { if err := BuildWingIndex(brainDir, w); err != nil {
continue return fmt.Errorf("index %s: %w", w, err)
}
if err := BuildWingIndex(brainDir, e.Name()); err != nil {
return fmt.Errorf("index %s: %w", e.Name(), err)
} }
} }
return nil return nil
} }
func collectWingEntries(wingDir string) ([]noteEntry, error) { func collectWingEntries(bfs *brainfs.Brain, wingRel string) ([]noteEntry, error) {
var out []noteEntry var out []noteEntry
ents, err := os.ReadDir(wingDir) halls, err := bfs.ReadDirDirs(wingRel)
if err != nil { if err != nil {
return nil, fmt.Errorf("read wing: %w", err) return nil, fmt.Errorf("read wing: %w", err)
} }
for _, hallEnt := range ents { for _, hall := range halls {
if !hallEnt.IsDir() {
continue
}
hall := hallEnt.Name()
if !IsValidHall(hall) { if !IsValidHall(hall) {
continue continue
} }
hallDir := filepath.Join(wingDir, hall) hallRel := wingRel + "/" + hall
notes, err := os.ReadDir(hallDir) names, err := bfs.ReadDirRegular(hallRel)
if err != nil { if err != nil {
return nil, fmt.Errorf("read hall %s: %w", hall, err) return nil, fmt.Errorf("read hall %s: %w", hall, err)
} }
for _, n := range notes { for _, name := range names {
if n.IsDir() || !strings.HasSuffix(n.Name(), ".md") || n.Name() == "_index.md" { if !strings.HasSuffix(name, ".md") || name == "_index.md" {
continue continue
} }
slug := strings.TrimSuffix(n.Name(), ".md") slug := strings.TrimSuffix(name, ".md")
full := filepath.Join(hallDir, n.Name()) title, created := readTitleAndCreatedIn(bfs, hallRel+"/"+name, slug)
title, created := readTitleAndCreated(full, slug)
out = append(out, noteEntry{Hall: hall, Slug: slug, Title: title, Created: created}) out = append(out, noteEntry{Hall: hall, Slug: slug, Title: title, Created: created})
} }
} }
return out, nil return out, nil
} }
// readTitleAndCreated reads YAML frontmatter for title + created_at; falls // readTitleAndCreatedIn is readTitleAndCreated through the brain root,
// back to slug and file mtime when absent. // regular files only (hyperguild#157).
func readTitleAndCreated(path, slug string) (string, string) { func readTitleAndCreatedIn(bfs *brainfs.Brain, rel, slug string) (string, string) {
f, err := os.Open(path) content, err := bfs.ReadRegular(rel)
if err != nil { if err != nil {
return slug, "" return slug, ""
} }
defer func() { _ = f.Close() }() title, created := parseTitleAndCreated(bytes.NewReader(content))
if created == "" {
if info, err := bfs.Lstat(rel); err == nil {
created = info.ModTime().UTC().Format("2006-01-02")
}
}
return titleOrSlug(title, slug), created
}
title, created := "", "" func titleOrSlug(title, slug string) string {
scanner := bufio.NewScanner(f) if title == "" {
return strings.ReplaceAll(slug, "-", " ")
}
return title
}
func parseTitleAndCreated(r io.Reader) (title, created string) {
scanner := bufio.NewScanner(r)
inFrontmatter := false inFrontmatter := false
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
@@ -149,13 +168,5 @@ func readTitleAndCreated(path, slug string) (string, string) {
} }
} }
} }
if title == "" {
title = strings.ReplaceAll(slug, "-", " ")
}
if created == "" {
if info, err := os.Stat(path); err == nil {
created = info.ModTime().UTC().Format("2006-01-02")
}
}
return title, created return title, created
} }
+132
View File
@@ -0,0 +1,132 @@
package brain_test
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
)
func readIndex(t *testing.T, brainDir, wing string) string {
t.Helper()
b, _ := os.ReadFile(filepath.Join(brainDir, "wiki", wing, "_index.md"))
return string(b)
}
// hyperguild#157 slice 5: BuildWingIndex listed wiki/<wing>/<hall>/ with
// os.ReadDir and opened each note by path, so a symlinked note put the
// target's title into _index.md, and a FIFO hung the index build.
func TestBuildWingIndexRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
b := t.TempDir()
facts := filepath.Join(b, "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")))
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(b, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
require.NoError(t, brain.BuildWingIndex(b, "w"))
idx := readIndex(t, b, "w")
assert.Contains(t, idx, "zqinside", "positive control")
assert.NotContains(t, idx, brainfstest.Sentinel)
assert.NotContains(t, idx, "leak")
assert.NotContains(t, idx, "inlink")
p := filepath.Join(facts, "r.md")
read := func() string {
_ = brain.BuildWingIndex(b, "w")
return readIndex(t, b, "w")
}
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() })
}
// A symlinked hall is skipped while a real hall next to it is indexed; a
// symlinked wing is refused and nothing is written into its target.
func TestBuildWingIndexSkipsASymlinkedHallAndRefusesASymlinkedWing(t *testing.T) {
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "raw", "facts", "r.md"), []byte("---\ntitle: zqrawonly\n---\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "decisions"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "decisions", "d.md"), []byte("---\ntitle: zqrealhall\n---\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "w", "facts")))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(b, "wiki", "alias")))
require.NoError(t, brain.BuildWingIndex(b, "w"))
idx, err := os.ReadFile(filepath.Join(b, "wiki", "w", "_index.md"))
require.NoError(t, err, "the wing index is written")
assert.Contains(t, string(idx), "zqrealhall", "the real hall is indexed")
assert.NotContains(t, string(idx), "zqrawonly", "the symlinked hall is not")
assert.Error(t, brain.BuildWingIndex(b, "alias"))
_, err = os.Stat(filepath.Join(b, "raw", "_index.md"))
assert.True(t, os.IsNotExist(err), "an index was written into the symlinked wing's target")
}
// BuildAllWingIndexes indexes the real wings and skips a symlinked one.
func TestBuildAllWingIndexesSkipsASymlinkedWing(t *testing.T) {
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "n.md"), []byte("---\ntitle: zqrealwing\n---\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "facts"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(b, "wiki", "alias")))
require.NoError(t, brain.BuildAllWingIndexes(b))
idx, err := os.ReadFile(filepath.Join(b, "wiki", "w", "_index.md"))
require.NoError(t, err)
assert.Contains(t, string(idx), "zqrealwing")
_, err = os.Stat(filepath.Join(b, "raw", "_index.md"))
assert.True(t, os.IsNotExist(err))
}
// A link to a symlinked note does not resolve: only regular files count as
// entries, so StripUnresolvedLinks unwraps it.
func TestStripUnresolvedLinksDoesNotCountASymlinkedNoteAsAnEntry(t *testing.T) {
b := t.TempDir()
facts := filepath.Join(b, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(facts, "real.md"), []byte("x"), 0o644))
brainfstest.WriteSentinelAt(t, filepath.Join(b, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "ghost.md")))
out, unlinked, err := brain.StripUnresolvedLinks(b, "see [[real]] and [[ghost]]")
require.NoError(t, err)
assert.Contains(t, out, "[[real]]", "positive control")
assert.NotContains(t, out, "[[ghost]]")
assert.Contains(t, unlinked, "ghost")
}
// A linked tree (knowledge/ or wiki/) that is not a real directory is
// skipped, not an error: StripUnresolvedLinks runs before every brain_write,
// and an error there loses the learning.
func TestStripUnresolvedLinksSkipsALinkedTreeThatIsNotARealDirectory(t *testing.T) {
for name, mk := range map[string]func(t *testing.T, b string){
"symlink to an in-brain dir": func(t *testing.T, b string) {
require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "k"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("raw", "k"), filepath.Join(b, "knowledge")))
},
"dangling symlink": func(t *testing.T, b string) {
require.NoError(t, os.Symlink("nowhere", filepath.Join(b, "knowledge")))
},
"regular file": func(t *testing.T, b string) {
require.NoError(t, os.WriteFile(filepath.Join(b, "knowledge"), []byte("x"), 0o644))
},
} {
t.Run(name, func(t *testing.T) {
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "real.md"), []byte("x"), 0o644))
mk(t, b)
out, _, err := brain.StripUnresolvedLinks(b, "learning [[real]] and [[gone]]")
require.NoError(t, err)
assert.Contains(t, out, "[[real]]", "links into wiki/ still resolve")
assert.NotContains(t, out, "[[gone]]")
})
}
}
+39 -19
View File
@@ -1,12 +1,16 @@
package brain package brain
import ( import (
"errors"
"fmt" "fmt"
"os" "io/fs"
"path/filepath" "log/slog"
"path"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
) )
// wikilinkRE matches [[target]] and [[target|Display Text]]. // wikilinkRE matches [[target]] and [[target|Display Text]].
@@ -43,7 +47,12 @@ var (
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) { func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
index, err := entryIndex(brainDir) index, err := entryIndex(brainDir)
if err != nil { if err != nil {
return "", nil, err // A partial index cannot tell an invented link from one into the
// part it could not read, so unwrap nothing, and do not refuse the
// write either: this runs before every brain_write, and a refused
// write is a lost learning (hyperguild#178).
slog.Warn("link check skipped: cannot index the brain", "err", err)
return content, nil, nil
} }
if len(index) == 0 { if len(index) == 0 {
return content, nil, nil return content, nil, nil
@@ -98,29 +107,40 @@ var linkedTrees = []string{"knowledge", "wiki"}
// one the audit will also resolve. // one the audit will also resolve.
func entryIndex(brainDir string) (map[string]struct{}, error) { func entryIndex(brainDir string) (map[string]struct{}, error) {
index := map[string]struct{}{} index := map[string]struct{}{}
for _, dir := range linkedTrees { // Walked through the brain root (hyperguild#157): only regular files
root := filepath.Join(brainDir, dir) // count as entries, so a symlinked .md name cannot make a link resolve.
if _, err := os.Stat(root); os.IsNotExist(err) { bfs, err := brainfs.Open(brainDir)
continue
} else if err != nil {
return nil, fmt.Errorf("stat %s: %w", dir, err)
}
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
return err return nil, fmt.Errorf("open brain: %w", err)
} }
if d.IsDir() || !strings.HasSuffix(path, ".md") { defer func() { _ = bfs.Close() }()
for _, dir := range linkedTrees {
if _, err := bfs.Lstat(dir); errors.Is(err, fs.ErrNotExist) {
continue
}
var walkErr error
err := bfs.WalkRegular(dir, func(rel string) error {
if !strings.HasSuffix(rel, ".md") {
return nil return nil
} }
rel, err := filepath.Rel(brainDir, path) index[strings.TrimSuffix(path.Base(rel), ".md")] = struct{}{}
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{} index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
return nil return nil
}, func(rel string, err error) {
// A linked tree that is not a real directory (a symlink, a
// file) is skipped, as filepath.WalkDir did: this runs before
// every brain_write, and an error here loses the learning.
if rel == dir && errors.Is(err, brainfs.ErrNotRegular) {
slog.Warn("link check: skipping a linked tree that is not a real directory", "dir", dir)
return
}
if walkErr == nil {
walkErr = fmt.Errorf("%s: %w", rel, err)
}
}) })
if err == nil {
err = walkErr
}
if err != nil { if err != nil {
return nil, fmt.Errorf("walk %s: %w", dir, err) return nil, fmt.Errorf("walk %s: %w", dir, err)
} }
@@ -0,0 +1,41 @@
package brain_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
)
// hyperguild#178: StripUnresolvedLinks runs before every brain_write and
// brain_update. An unreadable directory under knowledge/ or wiki/ made it
// error, the write was refused and the learning lost. When the link index
// cannot be built, the content is now returned unchanged (no link is
// unwrapped, none is invented) and no error stops the write.
func TestStripUnresolvedLinksKeepsContentWhenTheIndexCannotBeBuilt(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "real.md"), []byte("x"), 0o644))
locked := filepath.Join(b, "wiki", "other", "locked")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
in := "learning [[real]] and [[gone]]"
out, unlinked, err := brain.StripUnresolvedLinks(b, in)
require.NoError(t, err)
assert.Equal(t, in, out, "content unchanged: no link unwrapped on a partial index")
assert.Empty(t, unlinked)
require.NoError(t, os.Chmod(locked, 0o755))
out, _, err = brain.StripUnresolvedLinks(b, in)
require.NoError(t, err)
assert.NotContains(t, out, "[[gone]]", "positive control: with a readable tree the strip runs")
}
+96 -60
View File
@@ -2,11 +2,15 @@ package brain
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"os" "io/fs"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
) )
// seeAlsoHeader is the markdown heading used to group cross-wing links. // seeAlsoHeader is the markdown heading used to group cross-wing links.
@@ -36,24 +40,29 @@ type TunnelCandidate struct {
// A note's title is read from YAML frontmatter `title:`; failing that, // A note's title is read from YAML frontmatter `title:`; failing that,
// the filename slug (sans `.md`, hyphens → spaces) is used. // the filename slug (sans `.md`, hyphens → spaces) is used.
func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) { func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
wikiDir := filepath.Join(brainDir, "wiki") // Walked and read through the brain root, regular files only
if _, err := os.Stat(wikiDir); os.IsNotExist(err) { // (hyperguild#174, #157): this runs inside every brain_write, so a FIFO
// must not block it and a symlinked note's title must not become a
// candidate.
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
if _, err := bfs.Lstat("wiki"); errors.Is(err, fs.ErrNotExist) {
return nil, nil return nil, nil
} else if err != nil {
return nil, fmt.Errorf("stat wiki: %w", err)
} }
lowerContent := strings.ToLower(content) lowerContent := strings.ToLower(content)
var out []TunnelCandidate var out []TunnelCandidate
err := filepath.WalkDir(wikiDir, func(path string, d os.DirEntry, err error) error { var walkErr error
if err != nil { err = bfs.WalkRegular("wiki", func(rel string) error {
return err name := path.Base(rel)
} if !strings.HasSuffix(name, ".md") || name == "_index.md" {
if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" {
return nil return nil
} }
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md")) title, _ := readTitleAndCreatedIn(bfs, rel, strings.TrimSuffix(name, ".md"))
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
if title == "" { if title == "" {
return nil return nil
@@ -75,17 +84,25 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
if idx == -1 { if idx == -1 {
return nil return nil
} }
rel, err := filepath.Rel(brainDir, path)
if err != nil {
return err
}
out = append(out, TunnelCandidate{ out = append(out, TunnelCandidate{
TargetPath: filepath.ToSlash(rel), TargetPath: rel,
MatchedTerm: title, MatchedTerm: title,
Exact: isWholeWord(haystack, idx, len(needle)), Exact: isWholeWord(haystack, idx, len(needle)),
}) })
return nil return nil
}, func(rel string, err error) {
// A wiki/ that is not a real directory yields no candidates, as
// filepath.WalkDir did; other walk errors still abort.
if rel == "wiki" && errors.Is(err, brainfs.ErrNotRegular) {
return
}
if walkErr == nil {
walkErr = fmt.Errorf("%s: %w", rel, err)
}
}) })
if err == nil {
err = walkErr
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -158,35 +175,23 @@ func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error
if len(cs) == 0 { if len(cs) == 0 {
return nil return nil
} }
rawDir := filepath.Join(brainDir, "raw") // Appended through the brain root, never through a symlinked raw/ or
if err := os.MkdirAll(rawDir, 0o755); err != nil { // log file (hyperguild#158).
return err bfs, err := brainfs.Open(brainDir)
}
stamp := time.Now().UTC().Format("2006-01-02")
path := filepath.Join(rawDir, "tunnel-candidates-"+stamp+".md")
existed := fileExists(path)
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil { if err != nil {
return err return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = f.Close() }()
if !existed {
if _, err := f.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n"); err != nil {
return err
} }
defer func() { _ = bfs.Close() }()
stamp := time.Now().UTC().Format("2006-01-02")
rel := "raw/tunnel-candidates-" + stamp + ".md"
var buf strings.Builder
if _, err := bfs.Lstat(rel); errors.Is(err, fs.ErrNotExist) {
buf.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n")
} }
for _, c := range cs { for _, c := range cs {
line := fmt.Sprintf("- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm) fmt.Fprintf(&buf, "- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
if _, err := f.WriteString(line); err != nil {
return err
} }
} return bfs.AppendRegular(rel, []byte(buf.String()))
return nil
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
} }
// WriteTunnel appends a bidirectional wikilink between sourcePath and // WriteTunnel appends a bidirectional wikilink between sourcePath and
@@ -197,6 +202,12 @@ func fileExists(p string) bool {
// section headers. Rejects same-wing pairs (a tunnel is by definition // section headers. Rejects same-wing pairs (a tunnel is by definition
// cross-wing) and missing notes. // cross-wing) and missing notes.
func WriteTunnel(brainDir, sourcePath, targetPath string) error { func WriteTunnel(brainDir, sourcePath, targetPath string) error {
if err := checkNotePath(sourcePath); err != nil {
return fmt.Errorf("source: %w", err)
}
if err := checkNotePath(targetPath); err != nil {
return fmt.Errorf("target: %w", err)
}
srcWing, err := wingOf(sourcePath) srcWing, err := wingOf(sourcePath)
if err != nil { if err != nil {
return fmt.Errorf("source: %w", err) return fmt.Errorf("source: %w", err)
@@ -209,20 +220,50 @@ func WriteTunnel(brainDir, sourcePath, targetPath string) error {
return fmt.Errorf("tunnel must cross wings; got both in %q", srcWing) return fmt.Errorf("tunnel must cross wings; got both in %q", srcWing)
} }
srcFull := filepath.Join(brainDir, filepath.FromSlash(sourcePath)) // Both notes are read and rewritten through the brain root, regular
tgtFull := filepath.Join(brainDir, filepath.FromSlash(targetPath)) // files only: brain_tunnel passes caller-supplied paths, and the brain is
if _, err := os.Stat(srcFull); err != nil { // a Syncthing folder that can hold symlinks.
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
srcContent, err := bfs.ReadRegular(sourcePath)
if err != nil {
return fmt.Errorf("source note: %w", err) return fmt.Errorf("source note: %w", err)
} }
if _, err := os.Stat(tgtFull); err != nil { tgtContent, err := bfs.ReadRegular(targetPath)
if err != nil {
return fmt.Errorf("target note: %w", err) return fmt.Errorf("target note: %w", err)
} }
if err := appendSeeAlso(srcFull, wikilinkOf(targetPath)); err != nil { if out, changed, err := withSeeAlso(srcContent, wikilinkOf(targetPath)); err != nil {
return fmt.Errorf("update source: %w", err)
} else if changed {
if err := bfs.WriteRegular(sourcePath, out); err != nil {
return fmt.Errorf("update source: %w", err) return fmt.Errorf("update source: %w", err)
} }
if err := appendSeeAlso(tgtFull, wikilinkOf(sourcePath)); err != nil { }
if out, changed, err := withSeeAlso(tgtContent, wikilinkOf(sourcePath)); err != nil {
return fmt.Errorf("update target: %w", err) return fmt.Errorf("update target: %w", err)
} else if changed {
if err := bfs.WriteRegular(targetPath, out); err != nil {
return fmt.Errorf("update target: %w", err)
}
}
return nil
}
// checkNotePath accepts only a clean, local wiki/<wing>/<hall>/.../<slug>.md
// path: no "." or ".." segments, no empty segments, no leading slash.
func checkNotePath(p string) error {
if p == "" || path.Clean(p) != p || !filepath.IsLocal(p) || !strings.HasSuffix(p, ".md") {
return fmt.Errorf("not a clean wiki note path: %q", p)
}
for _, part := range strings.Split(p, "/") {
if part == "" || part == "." || part == ".." {
return fmt.Errorf("not a clean wiki note path: %q", p)
}
} }
return nil return nil
} }
@@ -248,16 +289,13 @@ func wingOf(relPath string) (string, error) {
return parts[1], nil return parts[1], nil
} }
// appendSeeAlso inserts `- [[link]]` under the file's See also section, // withSeeAlso returns content with `- [[link]]` under its See also section,
// creating the section if absent. No-op when the link is already present. // creating the section if absent. changed is false when the link is already
func appendSeeAlso(filePath, link string) error { // present.
content, err := os.ReadFile(filePath) func withSeeAlso(content []byte, link string) (out []byte, changed bool, err error) {
if err != nil {
return err
}
wikilink := "[[" + link + "]]" wikilink := "[[" + link + "]]"
if strings.Contains(string(content), wikilink) { if strings.Contains(string(content), wikilink) {
return nil return content, false, nil
} }
bullet := "- " + wikilink bullet := "- " + wikilink
@@ -266,8 +304,7 @@ func appendSeeAlso(filePath, link string) error {
// No section yet — append a fresh one. Always emit a trailing // No section yet — append a fresh one. Always emit a trailing
// newline so subsequent appends don't merge into the previous line. // newline so subsequent appends don't merge into the previous line.
trimmed := strings.TrimRight(string(content), "\n") trimmed := strings.TrimRight(string(content), "\n")
out := trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n" return []byte(trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n"), true, nil
return os.WriteFile(filePath, []byte(out), 0o644)
} }
// Section exists — splice the bullet in just before the next `## ` // Section exists — splice the bullet in just before the next `## `
@@ -293,12 +330,11 @@ func appendSeeAlso(filePath, link string) error {
b.WriteByte('\n') b.WriteByte('\n')
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return err return nil, false, err
} }
if !inserted { if !inserted {
// section was the last thing in the file — just append bullet // section was the last thing in the file — just append bullet
out := strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n" return []byte(strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n"), true, nil
return os.WriteFile(filePath, []byte(out), 0o644)
} }
return os.WriteFile(filePath, []byte(b.String()), 0o644) return []byte(b.String()), true, nil
} }
@@ -0,0 +1,38 @@
package brain_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
)
// hyperguild#173: brain_tunnel's write followed a directory symlink inside
// wiki/ that points elsewhere in the brain.
func TestWriteTunnelRefusesATargetBehindASymlinkedDirectory(t *testing.T) {
b := t.TempDir()
src := filepath.Join(b, "wiki", "a", "facts", "s.md")
require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755))
require.NoError(t, os.WriteFile(src, []byte("x\n"), 0o644))
victim := filepath.Join(b, "raw", "facts", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "b"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "b", "facts")))
assert.ErrorIs(t, brain.WriteTunnel(b, "wiki/a/facts/s.md", "wiki/b/facts/v.md"), brainfs.ErrNotRegular)
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched\n", string(got))
got, _ = os.ReadFile(src)
assert.Equal(t, "x\n", string(got), "the source is not rewritten either")
real := filepath.Join(b, "wiki", "c", "facts", "t.md")
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
require.NoError(t, os.WriteFile(real, []byte("t\n"), 0o644))
require.NoError(t, brain.WriteTunnel(b, "wiki/a/facts/s.md", "wiki/c/facts/t.md"), "positive control: a tunnel between real directories is written")
}
@@ -0,0 +1,141 @@
package brain_test
import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
)
// brain_tunnel passes caller-supplied source and target paths to WriteTunnel,
// which checked only "wiki/<wing>/..." and then joined them onto the brain
// dir: ".." segments resolved outside the brain and appendSeeAlso rewrote the
// file there.
func TestWriteTunnelRefusesATargetThatTraversesOutOfTheBrain(t *testing.T) {
brain0 := t.TempDir()
src := filepath.Join(brain0, "wiki", "a", "facts", "s.md")
require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755))
require.NoError(t, os.WriteFile(src, []byte("---\ntitle: S\n---\nbody\n"), 0o644))
victim := filepath.Join(t.TempDir(), "victim.md")
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
up := strings.Repeat("../", strings.Count(filepath.Clean(brain0), "/")+3)
target := "wiki/b/x/" + up + strings.TrimPrefix(victim, "/")
err := brain.WriteTunnel(brain0, "wiki/a/facts/s.md", target)
assert.Error(t, err)
got, rerr := os.ReadFile(victim)
require.NoError(t, rerr)
assert.Equal(t, "untouched\n", string(got), "a file outside the brain was modified")
}
func TestWriteTunnelRefusesUncleanOrNonWikiPaths(t *testing.T) {
brain0 := t.TempDir()
for _, p := range []string{"wiki/a/facts/s.md", "wiki/b/facts/t.md", "wiki/b/facts/t.txt", "raw/r.md"} {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(brain0, p)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(brain0, p), []byte("x\n"), 0o644))
}
for _, target := range []string{
"wiki/b/facts/../../../raw/r.md",
"wiki/b/./facts/t.md",
"wiki/b//facts/t.md",
"/wiki/b/facts/t.md",
"wiki/b/facts/t.txt",
"wiki/b/facts/",
} {
assert.Error(t, brain.WriteTunnel(brain0, "wiki/a/facts/s.md", target), target)
}
for _, p := range []string{"raw/r.md", "wiki/b/facts/t.txt"} {
got, err := os.ReadFile(filepath.Join(brain0, p))
require.NoError(t, err)
assert.Equal(t, "x\n", string(got), p)
}
require.NoError(t, brain.WriteTunnel(brain0, "wiki/a/facts/s.md", "wiki/b/facts/t.md"), "positive control")
got, err := os.ReadFile(filepath.Join(brain0, "wiki", "b", "facts", "t.md"))
require.NoError(t, err)
assert.Contains(t, string(got), "[[a/facts/s]]")
}
func TestWriteTunnelDoesNotWriteThroughASymlinkedNote(t *testing.T) {
brain0 := t.TempDir()
src := filepath.Join(brain0, "wiki", "a", "facts", "s.md")
require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755))
require.NoError(t, os.WriteFile(src, []byte("x\n"), 0o644))
tdir := filepath.Join(brain0, "wiki", "b", "facts")
require.NoError(t, os.MkdirAll(tdir, 0o755))
victim := filepath.Join(t.TempDir(), "victim.md")
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
require.NoError(t, os.Symlink(victim, filepath.Join(tdir, "t.md")))
assert.Error(t, brain.WriteTunnel(brain0, "wiki/a/facts/s.md", "wiki/b/facts/t.md"))
got, err := os.ReadFile(victim)
require.NoError(t, err)
assert.Equal(t, "untouched\n", string(got))
// The same with a link to a file elsewhere in the brain, which os.Root
// would follow.
inVictim := filepath.Join(brain0, "raw", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(inVictim), 0o755))
require.NoError(t, os.WriteFile(inVictim, []byte("untouched\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "v.md"), filepath.Join(tdir, "u.md")))
assert.Error(t, brain.WriteTunnel(brain0, "wiki/a/facts/s.md", "wiki/b/facts/u.md"))
got, err = os.ReadFile(inVictim)
require.NoError(t, err)
assert.Equal(t, "untouched\n", string(got))
got, err = os.ReadFile(src)
require.NoError(t, err)
assert.Equal(t, "x\n", string(got), "the source is not touched when the target is refused")
}
// The target is swapped between a regular note and a symlink to an in-brain
// file outside wiki/ while WriteTunnel runs: the read and the write each
// refuse a symlink, so the other file is never rewritten.
func TestWriteTunnelNeverRewritesASymlinkSwappedInForTheTarget(t *testing.T) {
brain0 := t.TempDir()
src := filepath.Join(brain0, "wiki", "a", "facts", "s.md")
require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755))
require.NoError(t, os.WriteFile(src, []byte("x\n"), 0o644))
tdir := filepath.Join(brain0, "wiki", "b", "facts")
require.NoError(t, os.MkdirAll(tdir, 0o755))
victim := filepath.Join(brain0, "raw", "victim.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
tgt := filepath.Join(tdir, "t.md")
require.NoError(t, os.WriteFile(tgt, []byte("t\n"), 0o644))
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
tmp := tgt + ".swap"
for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp)
if i%2 == 0 {
_ = os.WriteFile(tmp, []byte("t\n"), 0o644)
} else {
_ = os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), tmp)
}
_ = os.Rename(tmp, tgt)
}
}()
ok := 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
if brain.WriteTunnel(brain0, "wiki/a/facts/s.md", "wiki/b/facts/t.md") == nil {
ok++
}
_ = os.WriteFile(src, []byte("x\n"), 0o644)
}
stop.Store(true)
wg.Wait()
got, err := os.ReadFile(victim)
require.NoError(t, err)
assert.Equal(t, "untouched\n", string(got), "the swapped-in symlink's target was rewritten")
assert.Positive(t, ok, "positive control: some tunnels were written to the regular target")
}
@@ -0,0 +1,52 @@
package brain_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
)
// hyperguild#158: BuildWingIndex wrote wiki/<wing>/_index.md with
// os.WriteFile, so a symlinked _index.md was written through.
func TestBuildWingIndexDoesNotWriteThroughASymlinkedIndex(t *testing.T) {
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "n.md"), []byte("---\ntitle: zqn\n---\n"), 0o644))
victim := filepath.Join(b, "raw", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "v.md"), filepath.Join(b, "wiki", "w", "_index.md")))
assert.Error(t, brain.BuildWingIndex(b, "w"))
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched", string(got))
require.NoError(t, os.Remove(filepath.Join(b, "wiki", "w", "_index.md")))
require.NoError(t, brain.BuildWingIndex(b, "w"), "positive control")
idx, _ := os.ReadFile(filepath.Join(b, "wiki", "w", "_index.md"))
assert.Contains(t, string(idx), "zqn")
}
// AutoTunnel's fuzzy-candidate log appended to raw/tunnel-candidates-*.md
// with os.OpenFile, so a symlinked raw/ (or log file) was written through.
func TestAutoTunnelDoesNotAppendTheCandidateLogThroughASymlinkedRaw(t *testing.T) {
b := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "a", "facts"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "b", "facts"), 0o755))
// "zq widget" matches as a substring of "zq widgets", a fuzzy candidate.
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "b", "facts", "t.md"), []byte("---\ntitle: Zq Widget\n---\n"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(b, "raw")))
_ = brain.AutoTunnel(b, "wiki/a/facts/s.md", "about zq widgets")
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents, "the candidate log was written outside the brain")
require.NoError(t, os.Remove(filepath.Join(b, "raw")))
require.NoError(t, brain.AutoTunnel(b, "wiki/a/facts/s.md", "about zq widgets"))
logs, _ := filepath.Glob(filepath.Join(b, "raw", "tunnel-candidates-*.md"))
assert.Len(t, logs, 1, "positive control: the fuzzy candidate is logged in the real raw/")
}
+280 -4
View File
@@ -82,9 +82,224 @@ 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
}
// cleanRel refuses a path that is not clean and local. realDir checks the
// cleaned parent, so the I/O must use that same path: os.Root resolves ".."
// after following a symlink, and a ".." after a symlinked directory would
// otherwise pass the check and redirect the read or write.
func cleanRel(rel string) error {
if rel == "" || path.Clean(rel) != rel || !filepath.IsLocal(rel) {
return fmt.Errorf("%q: not a clean brain-relative path: %w", rel, ErrNotRegular)
}
return nil
}
// ReadDirDirs lists the real subdirectories directly in a brain-relative
// directory, by name. Every component of relDir must be a real directory
// (ErrNotRegular otherwise); symlinks to directories are left out.
func (b *Brain) ReadDirDirs(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().IsDir() {
names = append(names, e.Name())
}
}
return names, nil
}
// PutRegular creates or replaces a regular file at a brain-relative path,
// for creating writes (hyperguild#188, #158). Missing parent directories are
// created one component at a time through the root, and every existing
// component must be a real directory, so a symlinked directory cannot
// redirect the write elsewhere in the brain or outside it. An existing file
// is replaced only if it is regular (WriteRegular's checks); a new file is
// created with O_EXCL and confirmed to be the file that was opened. A
// directory swapped in after the checks is the #167 residual.
func (b *Brain) PutRegular(rel string, data []byte) error {
if err := cleanRel(rel); err != nil {
return err
}
if err := b.mkdirReal(path.Dir(rel)); err != nil {
return err
}
if _, err := b.root.Lstat(rel); err == nil {
return b.WriteRegular(rel, data)
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
return b.createNew(rel, data)
}
// CreateRegular creates a new regular file at a brain-relative path, with
// PutRegular's parent rules, and fails with fs.ErrExist if anything is
// already there: for writers that must never overwrite (promote, reject).
func (b *Brain) CreateRegular(rel string, data []byte) error {
if err := cleanRel(rel); err != nil {
return err
}
if err := b.mkdirReal(path.Dir(rel)); err != nil {
return err
}
return b.createNew(rel, data)
}
// createNew creates rel with O_EXCL and writes data only after confirming
// the path is the regular file it created.
func (b *Brain) createNew(rel string, data []byte) error {
f, err := b.root.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
hfi, herr := f.Stat()
lfi, lerr := b.root.Lstat(rel)
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
return fmt.Errorf("%s: changed while creating: %w", rel, ErrNotRegular)
}
if _, err := f.Write(data); err != nil {
return err
}
return f.Close()
}
// AppendRegular appends data to a regular file at a brain-relative path,
// creating it (and missing parent directories, as PutRegular does) if it is
// absent. It writes only after confirming the path is the regular file it
// opened, so a final symlink or special file, or one swapped in, is refused.
func (b *Brain) AppendRegular(rel string, data []byte) error {
if err := cleanRel(rel); err != nil {
return err
}
if err := b.mkdirReal(path.Dir(rel)); err != nil {
return err
}
if fi, err := b.root.Lstat(rel); err == nil && !fi.Mode().IsRegular() {
return fmt.Errorf("%s: %w", rel, ErrNotRegular)
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
f, err := b.root.OpenFile(rel, os.O_WRONLY|os.O_APPEND|os.O_CREATE|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
hfi, herr := f.Stat()
lfi, lerr := b.root.Lstat(rel)
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
return fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
}
if _, err := f.Write(data); err != nil {
return err
}
return f.Close()
}
// Remove removes the file at a clean brain-relative path through the root,
// refusing a path behind a symlinked directory. A final symlink is removed
// itself, never its target.
func (b *Brain) Remove(rel string) error {
if err := b.RealParent(rel); err != nil {
return err
}
return b.root.Remove(rel)
}
// RealParent returns ErrNotRegular unless rel is clean and every directory
// above it is a real directory, for writers that do their own I/O (an atomic
// rename) but must not be redirected by a symlinked directory.
func (b *Brain) RealParent(rel string) error {
if err := cleanRel(rel); err != nil {
return err
}
return b.realDir(path.Dir(rel))
}
// PutFile opens the brain dir as a root and calls PutRegular, for one-off
// writers that hold no Brain.
func PutFile(brainDir, rel string, data []byte) error {
b, err := Open(brainDir)
if err != nil {
return err
}
defer func() { _ = b.Close() }()
return b.PutRegular(rel, data)
}
// AppendFile opens the brain dir as a root and calls AppendRegular.
func AppendFile(brainDir, rel string, data []byte) error {
b, err := Open(brainDir)
if err != nil {
return err
}
defer func() { _ = b.Close() }()
return b.AppendRegular(rel, data)
}
// mkdirReal makes sure every component of rel exists as a real directory,
// creating missing ones through the root; an existing component that is
// not a real directory is ErrNotRegular.
func (b *Brain) mkdirReal(rel string) error {
if rel == "." {
return nil
}
prefix := ""
for _, part := range strings.Split(rel, "/") {
prefix = path.Join(prefix, part)
fi, err := b.root.Lstat(prefix)
if errors.Is(err, fs.ErrNotExist) {
if err := b.root.Mkdir(prefix, 0o755); err != nil && !errors.Is(err, fs.ErrExist) {
return err
}
fi, err = b.root.Lstat(prefix)
}
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("%s: component %s is not a directory: %w", rel, prefix, ErrNotRegular)
}
}
return 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) {
if err := cleanRel(rel); err != nil {
return nil, err
}
if err := b.realDir(path.Dir(rel)); err != nil {
return nil, err
}
fi, err := b.root.Lstat(rel) fi, err := b.root.Lstat(rel)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -92,7 +307,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.readOpened(rel) return b.readOpenedMax(rel, -1, fi)
} }
// 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
@@ -106,11 +321,17 @@ 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) } func (b *Brain) readOpened(rel string) ([]byte, error) { return b.readOpenedMax(rel, -1, nil) }
// ReadRegularMax is ReadRegular with a size limit, checked on the opened // 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).
func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) { func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) {
if err := cleanRel(rel); err != nil {
return nil, err
}
if err := b.realDir(path.Dir(rel)); err != nil {
return nil, err
}
fi, err := b.root.Lstat(rel) fi, err := b.root.Lstat(rel)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -118,10 +339,14 @@ 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) return b.readOpenedMax(rel, max, fi)
} }
func (b *Brain) readOpenedMax(rel string, max int64) ([]byte, error) { // readOpenedMax opens rel and reads it if the opened handle is a regular file
// and, when lfi is set, the same file the caller Lstat-ed: os.Root follows an
// in-root final symlink even with O_NOFOLLOW, so a symlink to another brain
// file swapped in between the Lstat and the open is refused only here.
func (b *Brain) readOpenedMax(rel string, max int64, lfi fs.FileInfo) ([]byte, error) {
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
@@ -134,6 +359,9 @@ func (b *Brain) readOpenedMax(rel string, max int64) ([]byte, error) {
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)
} }
@@ -199,3 +427,51 @@ func (b *Brain) WalkRegular(relDir string, fn func(rel string) error, onErr func
return fn(p) return fn(p)
}) })
} }
// WriteRegular replaces the contents of an existing regular file at a
// brain-relative path. It never creates a file and never writes through a
// final symlink: os.Root.OpenFile follows an in-root final symlink even with
// O_NOFOLLOW (verified on go1.27), so the path is Lstat-ed through the root
// first and the opened handle must be that same file (os.SameFile), which
// also catches a swap between the two. O_NONBLOCK keeps a FIFO from blocking
// the open. Every directory above the file must be a real directory
// (hyperguild#173); a directory swapped in after that check is the #167
// residual.
func (b *Brain) WriteRegular(rel string, data []byte) error {
if err := cleanRel(rel); err != nil {
return err
}
// Every directory above the file must be real: os.Root follows an
// in-brain intermediate symlink, which would redirect the write
// elsewhere in the brain (hyperguild#173). A swap after this check is
// the #167 residual.
if err := b.realDir(path.Dir(rel)); err != nil {
return err
}
lfi, err := b.root.Lstat(rel)
if err != nil {
return err
}
if !lfi.Mode().IsRegular() {
return fmt.Errorf("%s: %w", rel, ErrNotRegular)
}
f, err := b.root.OpenFile(rel, os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
fi, err := f.Stat()
if err != nil {
return err
}
if !fi.Mode().IsRegular() || !os.SameFile(lfi, fi) {
return fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
}
if err := f.Truncate(0); err != nil {
return err
}
if _, err := f.Write(data); err != nil {
return err
}
return f.Close()
}
+357
View File
@@ -6,6 +6,8 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"sync/atomic"
"syscall" "syscall"
"testing" "testing"
"time" "time"
@@ -189,3 +191,358 @@ 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 TestReadRegularDoesNotReturnAnInBrainFileSwappedInBehindASymlink(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")
}
func TestWriteRegularRefusesFinalSymlinksFIFOsEscapesAndMissingFilesAndRewritesRegularFiles(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(t.TempDir(), "o.md")
require.NoError(t, os.WriteFile(outside, []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "l.md")))
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "f.md"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "n.md"), []byte("old content"), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
assert.Error(t, b.WriteRegular("l.md", []byte("x")))
got, _ := os.ReadFile(outside)
assert.Equal(t, "untouched", string(got))
// A symlink to another file inside the brain: os.Root would allow it,
// only O_NOFOLLOW refuses it.
require.NoError(t, os.WriteFile(filepath.Join(dir, "in.md"), []byte("inside"), 0o644))
require.NoError(t, os.Symlink("in.md", filepath.Join(dir, "il.md")))
assert.Error(t, b.WriteRegular("il.md", []byte("x")))
got, _ = os.ReadFile(filepath.Join(dir, "in.md"))
assert.Equal(t, "inside", string(got))
// A reader held open, so a non-blocking write open would succeed. The
// Lstat type check refuses the FIFO first; the handle check and
// O_NONBLOCK matter only if it is swapped in after the Lstat.
r, err := os.OpenFile(filepath.Join(dir, "f.md"), os.O_RDONLY|syscall.O_NONBLOCK, 0)
require.NoError(t, err)
defer func() { _ = r.Close() }()
done := make(chan error, 1)
go func() { done <- b.WriteRegular("f.md", []byte("x")) }()
select {
case err := <-done:
assert.Error(t, err)
case <-time.After(3 * time.Second):
t.Fatal("WriteRegular hung on a FIFO")
}
assert.Error(t, b.WriteRegular("../"+filepath.Base(filepath.Dir(outside))+"/o.md", []byte("x")))
assert.Error(t, b.WriteRegular("missing.md", []byte("x")), "does not create files")
require.NoError(t, b.WriteRegular("n.md", []byte("new")))
got, _ = os.ReadFile(filepath.Join(dir, "n.md"))
assert.Equal(t, "new", string(got), "truncated and rewritten")
}
func TestReadDirDirsListsOnlyRealSubdirsAndRefusesSymlinkedComponents(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "real"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "other"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "other"), filepath.Join(dir, "a", "linked")))
require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "f.md"), []byte("f"), 0o644))
require.NoError(t, os.Symlink("a", filepath.Join(dir, "alias")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
names, err := b.ReadDirDirs("a")
require.NoError(t, err)
assert.Equal(t, []string{"real"}, names)
_, err = b.ReadDirDirs("alias")
assert.ErrorIs(t, err, ErrNotRegular)
}
// hyperguild#173: a file reached through a symlinked directory inside the
// brain is refused for reads and writes, so a caller-supplied path cannot be
// redirected to another part of the brain.
func TestReadAndWriteRegularRefuseAFileBehindASymlinkedDirectory(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
_, err = b.ReadRegular("wiki/alias/v.md")
assert.ErrorIs(t, err, ErrNotRegular)
_, err = b.ReadRegularMax("wiki/alias/v.md", 100)
assert.ErrorIs(t, err, ErrNotRegular)
assert.ErrorIs(t, b.WriteRegular("wiki/alias/v.md", []byte("x")), ErrNotRegular)
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
got, err = b.ReadRegular("raw/v.md")
require.NoError(t, err, "positive control: the real path still reads")
assert.Equal(t, "untouched", string(got))
}
// realDir checks the cleaned parent, so the I/O must use that same clean
// path: os.Root resolves ".." after following a symlink, and a ".." placed
// after a symlinked directory would otherwise pass the check and redirect.
func TestReadAndWriteRegularRefuseAPathThatIsNotClean(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw", "facts"), filepath.Join(dir, "wiki", "alias")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
for _, rel := range []string{"wiki/alias/../v.md", "./raw/v.md", "raw//v.md", "/raw/v.md"} {
got, err := b.ReadRegular(rel)
assert.ErrorIs(t, err, ErrNotRegular, rel)
assert.NotContains(t, string(got), "untouched", rel)
_, err = b.ReadRegularMax(rel, 100)
assert.ErrorIs(t, err, ErrNotRegular, rel)
assert.ErrorIs(t, b.WriteRegular(rel, []byte("OVERWRITTEN")), ErrNotRegular, rel)
}
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
}
// hyperguild#188: PutRegular creates or replaces a regular file through the
// root. Missing parent directories are created; an existing component that
// is not a real directory, a final symlink or special file, and an unclean
// path are refused, and nothing is created at a link's target.
func TestPutRegularCreatesOrReplacesOnlyThroughRealDirectories(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "inwing")))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "wiki", "outwing")))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "w"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "v.md"), filepath.Join(dir, "wiki", "w", "link.md")))
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "wiki", "w", "f.md"), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
for _, rel := range []string{"wiki/inwing/facts/n.md", "wiki/outwing/facts/n.md", "wiki/w/link.md", "wiki/w/f.md", "wiki/w/../../raw/v.md", "/abs.md"} {
assert.ErrorIs(t, b.PutRegular(rel, []byte("x")), ErrNotRegular, rel)
}
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents, "nothing created outside the brain")
_, err = os.Stat(filepath.Join(dir, "raw", "facts"))
assert.True(t, os.IsNotExist(err), "nothing created through the in-brain link")
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
require.NoError(t, b.PutRegular("wiki/new/facts/n.md", []byte("one")), "positive control: create with missing parents")
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
assert.Equal(t, "one", string(got))
require.NoError(t, b.PutRegular("wiki/new/facts/n.md", []byte("two")), "positive control: replace")
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
assert.Equal(t, "two", string(got))
}
func TestAppendRegularCreatesOrAppendsOnlyThroughRealDirectories(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "outdir")))
require.NoError(t, os.Symlink("raw", filepath.Join(dir, "indir")))
require.NoError(t, os.Symlink(filepath.Join("raw", "v.md"), filepath.Join(dir, "log.md")))
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "f.md"), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
for _, rel := range []string{"outdir/l.md", "indir/v.md", "log.md", "f.md", "raw/../log.md"} {
assert.ErrorIs(t, b.AppendRegular(rel, []byte("x")), ErrNotRegular, rel)
}
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents)
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
require.NoError(t, b.AppendRegular("logs/a.md", []byte("one\n")), "positive control: create")
require.NoError(t, b.AppendRegular("logs/a.md", []byte("two\n")), "positive control: append")
got, _ = os.ReadFile(filepath.Join(dir, "logs", "a.md"))
assert.Equal(t, "one\ntwo\n", string(got))
}
func TestRealParentRefusesASymlinkedDirectoryOrUncleanPath(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
assert.ErrorIs(t, b.RealParent("wiki/alias/x.md"), ErrNotRegular)
assert.ErrorIs(t, b.RealParent("wiki/../raw/x.md"), ErrNotRegular)
assert.NoError(t, b.RealParent("wiki/x.md"), "positive control")
}
// cleanRel must refuse an unclean path on its own: here the ".." resolves,
// after a symlinked directory, to a real regular file, so only the clean
// check stands between the call and that file.
func TestAppendRegularRefusesAnUncleanPathThatResolvesToARealFile(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "keep"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("raw", "keep"), filepath.Join(dir, "indir")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
assert.ErrorIs(t, b.AppendRegular("indir/../v.md", []byte("x")), ErrNotRegular)
assert.ErrorIs(t, b.PutRegular("indir/../v.md", []byte("x")), ErrNotRegular)
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
}
func TestRemoveRefusesAPathBehindASymlinkedDirectoryAndRemovesOnlyTheLink(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("keep"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias")))
require.NoError(t, os.Symlink(filepath.Join("..", "raw", "v.md"), filepath.Join(dir, "wiki", "link.md")))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "n.md"), []byte("n"), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
assert.ErrorIs(t, b.Remove("wiki/alias/v.md"), ErrNotRegular)
_, err = os.Stat(filepath.Join(dir, "raw", "v.md"))
assert.NoError(t, err, "the file behind the symlinked directory is kept")
require.NoError(t, b.Remove("wiki/link.md"))
_, err = os.Stat(filepath.Join(dir, "raw", "v.md"))
assert.NoError(t, err, "removing a final symlink never removes its target")
require.NoError(t, b.Remove("wiki/n.md"), "positive control")
_, err = os.Stat(filepath.Join(dir, "wiki", "n.md"))
assert.True(t, os.IsNotExist(err))
}
// CreateRegular is the only write path for promote and reject: it creates
// missing parents through the root, refuses an in-brain symlinked parent and
// an unclean path, and never replaces anything already there (fs.ErrExist,
// content unchanged), including a dangling link.
func TestCreateRegularCreatesOnlyNewFilesThroughRealDirectories(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "keep"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "inwing")))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("raw", "keep"), filepath.Join(dir, "indir")))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "exists.md"), []byte("original"), 0o644))
require.NoError(t, os.Symlink("nowhere.md", filepath.Join(dir, "wiki", "dangling.md")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
assert.ErrorIs(t, b.CreateRegular("wiki/inwing/facts/n.md", []byte("x")), ErrNotRegular)
_, err = os.Stat(filepath.Join(dir, "raw", "facts"))
assert.True(t, os.IsNotExist(err), "nothing created through the in-brain link")
assert.ErrorIs(t, b.CreateRegular("indir/../v.md", []byte("x")), ErrNotRegular)
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
assert.ErrorIs(t, b.CreateRegular("wiki/exists.md", []byte("x")), fs.ErrExist)
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "exists.md"))
assert.Equal(t, "original", string(got))
assert.ErrorIs(t, b.CreateRegular("wiki/dangling.md", []byte("x")), fs.ErrExist)
_, err = os.Stat(filepath.Join(dir, "wiki", "nowhere.md"))
assert.True(t, os.IsNotExist(err), "nothing created at the dangling link's target")
require.NoError(t, b.CreateRegular("wiki/new/facts/n.md", []byte("one")), "positive control: create with missing parents")
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
assert.Equal(t, "one", string(got))
}
// On Linux a backslash is an ordinary filename byte, not a separator, so a
// name containing one stays a single in-root component (hyperguild#158: the
// watcher retried such a note forever while cleanRel refused it).
func TestReadAndPutRegularTreatABackslashAsAFilenameByte(t *testing.T) {
dir := t.TempDir()
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
require.NoError(t, b.PutRegular(`a\..\b.md`, []byte("x")))
got, err := os.ReadFile(filepath.Join(dir, `a\..\b.md`))
require.NoError(t, err, "the name was not kept as one component")
assert.Equal(t, "x", string(got))
got, err = b.ReadRegular(`a\..\b.md`)
require.NoError(t, err)
assert.Equal(t, "x", string(got))
}
@@ -1,12 +1,15 @@
// Package brainfstest is the shared harness for hyperguild#157's swap-race // Package brainfstest is the shared harness for hyperguild#157's swap-race
// tests: it keeps swapping a regular note, a symlink to an outside file, or a // tests: it keeps swapping a regular note, a symlink to a sentinel target file
// FIFO (with a writer held open) into a path while a reader under test loops. // (outside the brain, or inside it for hyperguild#171), or a FIFO (with a
// It observes two things: no sentinel byte from the outside file reaches the // writer held open) into a path while a reader under test loops.
// reader's output, and the reader returns within a timeout. It does not cover // It observes two things, over a sampled window only: no sentinel byte from
// the target reaches the reader's output, and the reader returns within a
// timeout. It does not cover
// hard links, other paths into the reader, or races it does not generate. // hard links, other paths into the reader, or races it does not generate.
package brainfstest package brainfstest
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -17,32 +20,50 @@ import (
"time" "time"
) )
// Sentinel marks the outside file's contents. // Sentinel marks the target 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.TB) 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("# secret\n\n"+Sentinel), 0o644); err != nil { if err := os.WriteFile(p, []byte(outsideContent), 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
// handle check parks forever instead of reading EOF. The swapper is stopped // handle check parks forever instead of reading EOF. The swapper is stopped
// and awaited before t.TempDir cleanup. // and awaited before t.TempDir cleanup. It returns the number of symlinks
func StartSwap(t *testing.T, path, inside, outside string, fifo bool) { // or FIFOs actually renamed into path so far, so a caller can tell a race
// that ran from one whose every swap failed (hyperguild#177).
func StartSwap(t testing.TB, path, inside, outside string, fifo bool) *atomic.Int64 {
t.Helper() t.Helper()
var swaps atomic.Int64
var stop atomic.Bool var stop atomic.Bool
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
tmp := path + ".swap" // A name of its own: swappers on one path share a test, and one
// opening another's FIFO for write would block forever.
tmp := fmt.Sprintf("%s.swap%d", path, swapSeq.Add(1))
// A relative link: os.Root refuses every absolute link on its own, so
// an absolute in-brain target would test nothing.
link := outside
if rel, err := filepath.Rel(filepath.Dir(path), outside); err == nil {
link = rel
}
var writers []*os.File var writers []*os.File
defer func() { defer func() {
for _, w := range writers { for _, w := range writers {
@@ -51,35 +72,84 @@ func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
}() }()
for i := 0; !stop.Load(); i++ { for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp) _ = os.Remove(tmp)
var made error
switch { switch {
case i%2 == 0: case i%2 == 0:
_ = os.WriteFile(tmp, []byte(inside), 0o644) _ = os.WriteFile(tmp, []byte(inside), 0o644)
case fifo: case fifo:
if err := syscall.Mkfifo(tmp, 0o644); err == nil { if made = syscall.Mkfifo(tmp, 0o644); made == nil {
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil { if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
writers = append(writers, w) writers = append(writers, w)
} }
} }
default: default:
_ = os.Symlink(outside, tmp) made = os.Symlink(link, tmp)
}
if err := os.Rename(tmp, path); err == nil && made == nil && i%2 == 1 {
swaps.Add(1)
} }
_ = os.Rename(tmp, path)
} }
}() }()
t.Cleanup(func() { stop.Store(true); wg.Wait() }) t.Cleanup(func() { stop.Store(true); wg.Wait() })
return &swaps
}
// 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. // fails if any output contains Sentinel. The symlink points outside the
// brain; os.Root refuses that on its own, so pair it with AssertNoLeakFrom
// and an in-brain target.
func AssertNoLeak(t *testing.T, path, inside string, d time.Duration, read func() string) { func AssertNoLeak(t *testing.T, path, inside string, d time.Duration, read func() string) {
t.Helper() t.Helper()
StartSwap(t, path, inside, Outside(t), false) AssertNoLeakFrom(t, path, inside, Outside(t), d, read)
leaks, iters := 0, 0 }
// InsideMarker must appear in the inside note's content and in read's
// output whenever read saw that note: the floor below counts such reads.
const InsideMarker = "inside"
// AssertNoLeakFrom is AssertNoLeak with the symlink pointing at target, e.g.
// a file elsewhere in the brain written with WriteSentinelAt.
//
// Beyond "no Sentinel in any output" it sets two floors, because without
// them the assertion passes when nothing was tested (hyperguild#177): at
// least one symlink must have been swapped into path, and at least one read
// must have returned InsideMarker (a reader that always errors returns
// nothing, and so never leaks).
func AssertNoLeakFrom(t testing.TB, path, inside, target string, d time.Duration, read func() string) {
t.Helper()
if !strings.Contains(inside, InsideMarker) {
t.Fatalf("inside content must contain %q", InsideMarker)
}
swaps := StartSwap(t, path, inside, target, false)
leaks, iters, insideReads := 0, 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) out := read()
leaks += strings.Count(out, Sentinel)
if strings.Contains(out, InsideMarker) {
insideReads++
}
} }
if leaks > 0 { if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters) t.Fatalf("symlink target %s leaked %d times in %d iterations", target, leaks, iters)
}
if n := swaps.Load(); n == 0 {
t.Fatalf("no symlink was swapped into %s in %d iterations: the race did not run", path, iters)
}
if insideReads == 0 {
t.Fatalf("no read in %d iterations returned the inside note (%q): the reader read nothing", iters, InsideMarker)
} }
} }
@@ -0,0 +1,79 @@
package brainfstest
import (
"fmt"
"os"
"path/filepath"
"runtime"
"syscall"
"testing"
"time"
)
// recordingTB stands in for the test under AssertNoLeakFrom so a fatal
// there can be observed instead of failing this test. Fatalf ends the
// calling goroutine the way testing.T does.
type recordingTB struct {
testing.TB
failed bool
msg string
}
func (r *recordingTB) Fatalf(format string, args ...any) {
r.failed, r.msg = true, fmt.Sprintf(format, args...)
runtime.Goexit()
}
func runAssert(t *testing.T, path, target string, read func() string) *recordingTB {
t.Helper()
r := &recordingTB{TB: t}
done := make(chan struct{})
go func() {
defer close(done)
AssertNoLeakFrom(r, path, "inside", target, 200*time.Millisecond, read)
}()
<-done
return r
}
// hyperguild#177: with every swap failing, no leak can occur, so the leak
// assertion passed without testing anything.
func TestAssertNoLeakFromFailsWhenNoSymlinkSwapSucceeds(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing-dir", "r.md")
r := runAssert(t, path, Outside(t), func() string { return "inside" })
if !r.failed {
t.Fatal("AssertNoLeakFrom passed with zero successful symlink swaps")
}
}
// hyperguild#177: a reader that always errors returns nothing, so it never
// leaks either.
func TestAssertNoLeakFromFailsWhenNoReadReturnsTheInsideNote(t *testing.T) {
path := filepath.Join(t.TempDir(), "r.md")
r := runAssert(t, path, Outside(t), func() string { return "" })
if !r.failed {
t.Fatal("AssertNoLeakFrom passed with zero reads returning the inside note")
}
}
// Positive control: a reader that refuses symlinks and reads regular files
// passes, so the floors do not fail a working reader.
func TestAssertNoLeakFromPassesAReaderThatRefusesSymlinks(t *testing.T) {
path := filepath.Join(t.TempDir(), "r.md")
read := func() string {
f, err := os.OpenFile(path, os.O_RDONLY|noFollow, 0)
if err != nil {
return ""
}
defer func() { _ = f.Close() }()
b := make([]byte, 64)
n, _ := f.Read(b)
return string(b[:n])
}
r := runAssert(t, path, Outside(t), read)
if r.failed {
t.Fatalf("a symlink-refusing reader failed: %s", r.msg)
}
}
const noFollow = syscall.O_NOFOLLOW
@@ -16,9 +16,10 @@
package classification package classification
import ( import (
"errors"
"fmt" "fmt"
"os" "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"path/filepath" "io/fs"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -110,9 +111,20 @@ 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{}}
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml")) // Read through the brain root, regular files only (hyperguild#157): a
// symlinked or special file is not trusted as the config, which is a hard
// error like a malformed one.
bfs, err := brainfs.Open(brainDir)
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil
}
if err != nil { if err != nil {
if os.IsNotExist(err) { return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
data, err := bfs.ReadRegular("classification.yaml")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil return cfg, nil
} }
return nil, fmt.Errorf("read classification.yaml: %w", err) return nil, fmt.Errorf("read classification.yaml: %w", err)
@@ -0,0 +1,46 @@
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")
}
@@ -88,7 +88,6 @@ func TestNoPackageReferencesExtractTextOutsideExtract(t *testing.T) {
// osOpenAllowed names the functions in guarded files that may call os.Open* // osOpenAllowed names the functions in guarded files that may call os.Open*
// on a path that is not a raw/ read. // on a path that is not a raw/ read.
var osOpenAllowed = map[string]bool{ 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 // Text reads by path; outside this package only TextIn's temp copy reaches
// it (TestNoPackageReferencesExtractTextOutsideExtract enforces that). // it (TestNoPackageReferencesExtractTextOutsideExtract enforces that).
"internal/extract/extract.go:Text": true, "internal/extract/extract.go:Text": true,
+23 -2
View File
@@ -2,6 +2,7 @@
package extract package extract
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -50,6 +51,18 @@ func fileExt(path string) string {
// external tool (pdf, docmark) are copied to a temp file from the handle // external tool (pdf, docmark) are copied to a temp file from the handle
// opened through the root, then extracted from the copy. // opened through the root, then extracted from the copy.
func TextIn(r *os.Root, rel string) (string, error) { func TextIn(r *os.Root, rel string) (string, error) {
return TextInMax(r, rel, DefaultMaxBytes)
}
// DefaultMaxBytes bounds what TextIn reads or copies from one file.
const DefaultMaxBytes = 64 << 20
// ErrTooLarge reports a file that exceeded the read cap while being read: the
// size seen at an earlier Lstat is not a bound, the file may have grown.
var ErrTooLarge = errors.New("file exceeds the read cap")
// TextInMax is TextIn with an explicit cap on the bytes read (hyperguild#198).
func TextInMax(r *os.Root, rel string, max int64) (string, error) {
fi, err := r.Lstat(rel) fi, err := r.Lstat(rel)
if err != nil { if err != nil {
return "", fmt.Errorf("stat %s: %w", rel, err) return "", fmt.Errorf("stat %s: %w", rel, err)
@@ -65,10 +78,13 @@ func TextIn(r *os.Root, rel string) (string, error) {
ext := strings.ToLower(fileExt(rel)) ext := strings.ToLower(fileExt(rel))
switch ext { switch ext {
case ".md", ".txt": case ".md", ".txt":
b, err := io.ReadAll(f) b, err := io.ReadAll(io.LimitReader(f, max+1))
if err != nil { if err != nil {
return "", fmt.Errorf("read %s: %w", rel, err) return "", fmt.Errorf("read %s: %w", rel, err)
} }
if int64(len(b)) > max {
return "", fmt.Errorf("%s: %w (%d bytes)", rel, ErrTooLarge, max)
}
return string(b), nil return string(b), nil
case ".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg": case ".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
tmp, err := os.CreateTemp("", "ingest-*"+ext) tmp, err := os.CreateTemp("", "ingest-*"+ext)
@@ -76,10 +92,15 @@ func TextIn(r *os.Root, rel string) (string, error) {
return "", fmt.Errorf("temp copy: %w", err) return "", fmt.Errorf("temp copy: %w", err)
} }
defer func() { _ = os.Remove(tmp.Name()) }() defer func() { _ = os.Remove(tmp.Name()) }()
if _, err := io.Copy(tmp, f); err != nil { n, err := io.Copy(tmp, io.LimitReader(f, max+1))
if err != nil {
_ = tmp.Close() _ = tmp.Close()
return "", fmt.Errorf("copy %s: %w", rel, err) return "", fmt.Errorf("copy %s: %w", rel, err)
} }
if n > max {
_ = tmp.Close()
return "", fmt.Errorf("%s: %w (%d bytes)", rel, ErrTooLarge, max)
}
if err := tmp.Close(); err != nil { if err := tmp.Close(); err != nil {
return "", fmt.Errorf("copy %s: %w", rel, err) return "", fmt.Errorf("copy %s: %w", rel, err)
} }
@@ -0,0 +1,62 @@
package extract
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func rootWith(t *testing.T, name string, size int) *os.Root {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(strings.Repeat("x", size)), 0o644))
r, err := os.OpenRoot(dir)
require.NoError(t, err)
t.Cleanup(func() { _ = r.Close() })
return r
}
// hyperguild#198: the size check in the watcher is a pre-check on Lstat; a note
// that grows before it is read must still be capped at the read.
func TestTextInMax(t *testing.T) {
tests := []struct {
name string
size int
max int64
wantErr bool
}{
{"under the cap", 100, 1024, false},
{"exactly at the cap", 1024, 1024, false},
{"one byte over", 1025, 1024, true},
{"far over", 1 << 20, 1024, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := rootWith(t, "n.md", tt.size)
got, err := TextInMax(r, "n.md", tt.max)
if tt.wantErr {
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTooLarge))
return
}
require.NoError(t, err)
assert.Len(t, got, tt.size)
})
}
}
func TestTextInMax_TheBinaryTempCopyIsCappedToo(t *testing.T) {
r := rootWith(t, "n.pdf", 4096)
_, err := TextInMax(r, "n.pdf", 1024)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTooLarge), "the temp copy of a pdf must not exceed the cap either: %v", err)
}
func TestTextIn_HasADefaultCap(t *testing.T) {
assert.Positive(t, DefaultMaxBytes)
}
+43 -21
View File
@@ -12,10 +12,14 @@ package graphsync
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "io/fs"
"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"
) )
@@ -46,8 +50,25 @@ func IndexDoc(ctx context.Context, store Store, brainDir, relPath string) error
if relPath == "" { if relPath == "" {
return nil return nil
} }
abs := filepath.Join(brainDir, filepath.FromSlash(relPath)) // Callers pass caller-supplied paths (brain_tunnel, brain_update): index
content, err := os.ReadFile(abs) // only a clean path under wiki/ or knowledge/.
if path.Clean(relPath) != relPath || !filepath.IsLocal(relPath) ||
(!strings.HasPrefix(relPath, "wiki/") && !strings.HasPrefix(relPath, "knowledge/")) {
return fmt.Errorf("index %q: not a clean wiki/ or knowledge/ path", relPath)
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
return indexDoc(ctx, store, bfs, relPath)
}
// indexDoc reads relPath through the brain root, regular files only
// (hyperguild#157): the brain is a Syncthing folder, and os.ReadFile
// followed a synced symlink into the graph and blocked on a FIFO.
func indexDoc(ctx context.Context, store Store, bfs *brainfs.Brain, relPath string) error {
content, err := bfs.ReadRegular(relPath)
if err != nil { if err != nil {
return fmt.Errorf("read %q: %w", relPath, err) return fmt.Errorf("read %q: %w", relPath, err)
} }
@@ -77,33 +98,34 @@ func BackfillFromBrainDir(ctx context.Context, store Store, brainDir string) (in
if store == nil { if store == nil {
return 0, nil return 0, nil
} }
roots := []string{"wiki", "knowledge"} bfs, err := brainfs.Open(brainDir)
for _, root := range roots { if err != nil {
base := filepath.Join(brainDir, root) return 0, fmt.Errorf("open brain: %w", err)
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
} }
err := filepath.WalkDir(base, func(path string, d os.DirEntry, walkErr error) error { // Any walk error, including a root that is a symlink, aborts.
if walkErr != nil { var walkErr error
return walkErr err := bfs.WalkRegular(root, func(rel string) error {
} if path.Ext(rel) != ".md" {
if d.IsDir() {
return nil return nil
} }
if filepath.Ext(path) != ".md" { if err := indexDoc(ctx, store, bfs, rel); err != nil {
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)
} }
@@ -0,0 +1,92 @@
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)
}
}
+6 -2
View File
@@ -94,6 +94,7 @@ func (s *Server) tools() []map[string]any {
"wing": str("target wing, e.g. jepa-fx"), "wing": str("target wing, e.g. jepa-fx"),
"hall": enum("target hall", halls...), "hall": enum("target hall", halls...),
"slug": str("optional target slug; defaults to filename minus date prefix"), "slug": str("optional target slug; defaults to filename minus date prefix"),
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
}), }),
}, },
{ {
@@ -102,6 +103,7 @@ func (s *Server) tools() []map[string]any {
"inputSchema": schema([]string{"filename"}, map[string]any{ "inputSchema": schema([]string{"filename"}, map[string]any{
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"), "filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
"reason": str("optional; why this isn't worth promoting"), "reason": str("optional; why this isn't worth promoting"),
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
}), }),
}, },
noteToolDescriptors()[0], noteToolDescriptors()[0],
@@ -383,6 +385,7 @@ type brainPromoteArgs struct {
Wing string `json:"wing"` Wing string `json:"wing"`
Hall string `json:"hall"` Hall string `json:"hall"`
Slug string `json:"slug,omitempty"` Slug string `json:"slug,omitempty"`
Queue string `json:"queue,omitempty"`
} }
// brainPromote moves a raw/ note into the structured wiki (frontmatter // brainPromote moves a raw/ note into the structured wiki (frontmatter
@@ -395,7 +398,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
} }
origin, writtenBy := provenanceFrom(ctx) origin, writtenBy := provenanceFrom(ctx)
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{ relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, Queue: a.Queue,
Origin: origin, WrittenBy: writtenBy, Origin: origin, WrittenBy: writtenBy,
}) })
if err != nil { if err != nil {
@@ -408,6 +411,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
type brainRejectArgs struct { type brainRejectArgs struct {
Filename string `json:"filename"` Filename string `json:"filename"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Queue string `json:"queue,omitempty"`
} }
// brainReject records a decision not to promote a raw/ note (api.RejectNote: // brainReject records a decision not to promote a raw/ note (api.RejectNote:
@@ -418,7 +422,7 @@ func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawM
if err := json.Unmarshal(args, &a); err != nil { if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err) return nil, fmt.Errorf("parse args: %w", err)
} }
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason}) relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason, Queue: a.Queue})
if err != nil { if err != nil {
return nil, err return nil, err
} }
+25
View File
@@ -431,3 +431,28 @@ func TestBrainRejectMissingSourceErrors(t *testing.T) {
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"}) resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
require.NotNil(t, resp["error"]) require.NotNil(t, resp["error"])
} }
// brain_pending lists two entries for one name when a top-level note and a
// parked one share it; the tool must let the caller say which it means.
func TestBrainPromoteAndRejectTakeTheQueueOfAnAmbiguousName(t *testing.T) {
brainDir := t.TempDir()
raw := filepath.Join(brainDir, "raw")
require.NoError(t, os.MkdirAll(filepath.Join(raw, "needs-review"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(raw, "a.md"), []byte("---\ncreated_at: 2026-10-20T00:00:00Z\n---\nTOP\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(raw, "needs-review", "a.md"), []byte("---\nproposed_wing: homelab\n---\nPARKED\n"), 0o644))
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts"})
require.NotNil(t, resp["error"], "an ambiguous name is refused, not guessed")
resp = toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts", "queue": "needs-review"})
require.Nil(t, resp["error"], "got: %v", resp["error"])
got, err := os.ReadFile(filepath.Join(brainDir, "wiki/homelab/facts/a.md"))
require.NoError(t, err)
assert.Contains(t, string(got), "PARKED")
resp = toolCall(t, srv, "brain_reject", map[string]any{"filename": "a.md", "queue": "inbox", "reason": "no"})
require.Nil(t, resp["error"], "got: %v", resp["error"])
_, err = os.Stat(filepath.Join(brainDir, "raw/rejected/a.md"))
assert.NoError(t, err)
}
@@ -21,6 +21,11 @@ func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
require.NoError(t, os.MkdirAll(facts, 0o755)) require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md"))) require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/leak.md", 500), brainfstest.Sentinel) assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/leak.md", 500), brainfstest.Sentinel)
// A link to a file elsewhere in the brain: os.Root allows it, only the
// regular-file check refuses it (hyperguild#171).
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.NotContains(t, readExcerpt(brain, "wiki/w/facts/inlink.md", 500), brainfstest.Sentinel)
p := filepath.Join(facts, "r.md") p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644)) require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
@@ -28,6 +33,9 @@ func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, func() string { brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, func() string {
return readExcerpt(brain, "wiki/w/facts/r.md", 500) return readExcerpt(brain, "wiki/w/facts/r.md", 500)
}) })
brainfstest.AssertNoLeakFrom(t, p, "---\nx: y\n---\ninside", inBrain, 1500*time.Millisecond, func() string {
return readExcerpt(brain, "wiki/w/facts/r.md", 500)
})
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() {
_ = readExcerpt(brain, "wiki/w/facts/r.md", 500) _ = readExcerpt(brain, "wiki/w/facts/r.md", 500)
}) })
+4 -3
View File
@@ -83,8 +83,9 @@ func (r *Registry) Observe(path, status string, d time.Duration) {
// Middleware wraps next, observing every request's duration + status. // Middleware wraps next, observing every request's duration + status.
// The metric label `path` uses the request's Pattern (Go 1.22+ ServeMux), // The metric label `path` uses the request's Pattern (Go 1.22+ ServeMux),
// falling back to the URL path if no Pattern is set. Pattern keeps // or the fixed label "unmatched" if no Pattern is set: falling back to the
// cardinality bounded (one series per route, not one per unique URL). // raw URL path gave any caller one series per unique path (hyperguild#185).
// Pattern keeps cardinality bounded (one series per route).
func (r *Registry) Middleware(next http.Handler) http.Handler { func (r *Registry) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK} rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK}
@@ -92,7 +93,7 @@ func (r *Registry) Middleware(next http.Handler) http.Handler {
next.ServeHTTP(rec, req) next.ServeHTTP(rec, req)
path := req.Pattern path := req.Pattern
if path == "" { if path == "" {
path = req.URL.Path path = "unmatched"
} }
r.Observe(path, statusClass(rec.code), time.Since(start)) r.Observe(path, statusClass(rec.code), time.Since(start))
}) })
+23 -2
View File
@@ -71,11 +71,16 @@ func TestMiddleware_RecordsTiming(t *testing.T) {
t.Parallel() t.Parallel()
r := New() r := New()
handler := r.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Routed through a ServeMux, as in production: the mux sets the
// request's Pattern, which is the label (an unrouted request is
// "unmatched", hyperguild#185).
mux := http.NewServeMux()
mux.HandleFunc("/query", func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * time.Millisecond) time.Sleep(2 * time.Millisecond)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok") _, _ = io.WriteString(w, "ok")
})) })
handler := r.Middleware(mux)
srv := httptest.NewServer(handler) srv := httptest.NewServer(handler)
defer srv.Close() defer srv.Close()
@@ -117,3 +122,19 @@ func TestStatusRecorder_DefaultsTo200(t *testing.T) {
t.Errorf("code %d, want 200", rec.Code) t.Errorf("code %d, want 200", rec.Code)
} }
} }
// A request with no matched Pattern is labelled "unmatched", never with its
// raw URL path (unbounded cardinality, hyperguild#185).
func TestMiddleware_LabelsAnUnmatchedRequestWithAFixedLabel(t *testing.T) {
r := New()
h := r.Middleware(http.NotFoundHandler())
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/zqrandom-185-unit", nil))
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
if strings.Contains(rec.Body.String(), "zqrandom-185-unit") {
t.Errorf("the raw path became a metrics label:\n%s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `path="unmatched"`) {
t.Errorf("no unmatched label:\n%s", rec.Body.String())
}
}
@@ -0,0 +1,36 @@
package notes
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#188/#173: brain_edit's atomic rename must not be redirected by
// a symlinked directory inside the brain.
func TestEditRefusesANoteBehindASymlinkedDirectory(t *testing.T) {
b := t.TempDir()
// Edit refuses a tree that is not the sync source before any path
// check, so the test needs a .git to reach the symlink refusal.
require.NoError(t, os.MkdirAll(filepath.Join(b, ".git"), 0o755))
victim := filepath.Join(b, "raw", "facts", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("old text\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "w", "alias")))
_, err := Edit(b, "wiki/w/alias/v.md", "old text", "new text", ScopeNotes)
assert.Error(t, err)
got, _ := os.ReadFile(victim)
assert.Equal(t, "old text\n", string(got))
real := filepath.Join(b, "wiki", "w", "facts", "r.md")
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
require.NoError(t, os.WriteFile(real, []byte("old text\n"), 0o644))
_, err = Edit(b, "wiki/w/facts/r.md", "old text", "new text", ScopeNotes)
require.NoError(t, err, "positive control: a note in a real directory is edited")
got, _ = os.ReadFile(real)
assert.Equal(t, "new text\n", string(got))
}
@@ -38,3 +38,31 @@ func TestReadScopedRefusesANoteOverMaxNoteBytes(t *testing.T) {
_, err := ReadScoped(brain, "wiki/w/facts/big.md", ScopeNotes) _, err := ReadScoped(brain, "wiki/w/facts/big.md", ScopeNotes)
assert.ErrorIs(t, err, ErrTooLarge) assert.ErrorIs(t, err, ErrTooLarge)
} }
// hyperguild#171 (review of #172, finding 1): os.Root alone refuses links that
// leave the brain, so the outside-target race above stays green for a reader
// that skips brainfs's Lstat and SameFile checks. An in-brain target (raw/,
// outside ScopeNotes) is what tells them apart.
func TestReadScopedDoesNotReturnAnInBrainSymlinkTargetStaticallyOrDuringASampledSwapRace(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
target := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
b, err := ReadScoped(brain, "wiki/w/facts/inlink.md", ScopeNotes)
assert.Error(t, err, "static in-brain link")
assert.NotContains(t, string(b), brainfstest.Sentinel, "static in-brain link")
p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("inside"), 0o644))
b, err = ReadScoped(brain, "wiki/w/facts/r.md", ScopeNotes)
require.NoError(t, err)
assert.Equal(t, "inside", string(b), "positive control: a regular note reads")
read := func() string {
b, _ := ReadScoped(brain, "wiki/w/facts/r.md", ScopeNotes)
return string(b)
}
brainfstest.AssertNoLeakFrom(t, p, "inside", target, 1500*time.Millisecond, read)
}
+18 -12
View File
@@ -3,9 +3,10 @@ package pipeline
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"path/filepath" "io/fs"
"strings" "strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki" "github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
@@ -23,10 +24,16 @@ 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)
} }
sourcesDir := filepath.Join(brainDir, "wiki", "sources") // Listed and read through the brain root, regular files only
entries, err := os.ReadDir(sourcesDir) // (hyperguild#157).
bfs, err := brainfs.Open(brainDir)
if err != nil { if err != nil {
if os.IsNotExist(err) { return 0, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
names, err := bfs.ReadDirRegular("wiki/sources")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return 0, nil return 0, nil
} }
return 0, fmt.Errorf("read sources dir: %w", err) return 0, fmt.Errorf("read sources dir: %w", err)
@@ -37,20 +44,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 _, e := range entries { for _, name := range names {
if ctx.Err() != nil { if ctx.Err() != nil {
return 0, ctx.Err() return 0, ctx.Err()
} }
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { if !strings.HasSuffix(name, ".md") {
continue continue
} }
b, err := os.ReadFile(filepath.Join(sourcesDir, e.Name())) b, err := bfs.ReadRegular("wiki/sources/" + name)
if err != nil { if err != nil {
continue continue
} }
sourceContent := string(b) sourceContent := string(b)
sourceSlug := strings.TrimSuffix(e.Name(), ".md") sourceSlug := strings.TrimSuffix(name, ".md")
sourceTitle := extractTitle(sourceContent) sourceTitle := extractTitle(sourceContent)
if sourceTitle == "" { if sourceTitle == "" {
sourceTitle = sourceSlug sourceTitle = sourceSlug
@@ -70,7 +77,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 := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(relPath))) raw, err := bfs.ReadRegular(relPath)
if err != nil { if err != nil {
continue continue
} }
@@ -81,8 +88,7 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
} }
for relPath, page := range pending { for relPath, page := range pending {
dest := filepath.Join(brainDir, filepath.FromSlash(relPath)) if err := bfs.PutRegular(relPath, []byte(page.Content)); err != nil {
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil {
return 0, fmt.Errorf("write %s: %w", relPath, err) return 0, fmt.Errorf("write %s: %w", relPath, err)
} }
} }
+29 -3
View File
@@ -14,12 +14,21 @@ import (
// (derived from the filename, not the LLM title). Pages whose title resolves to an // (derived from the filename, not the LLM title). Pages whose title resolves to an
// empty slug are skipped and returned as warnings instead. // empty slug are skipped and returned as warnings instead.
func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) { func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
return BuildPagesIn("", rawPages, sourceSlug, date)
}
// BuildPagesIn is BuildPages for a wing: a source goes to wiki/<wing>/sources/
// with wing/hall frontmatter. Concepts and entities stay in the shared
// wiki/concepts/ and wiki/entities/, since their slug is the graph's global
// identity and they are cited from many wings. An empty wing is the retired
// layout for sources too.
func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
out := make([]wiki.Page, 0, len(rawPages)) out := make([]wiki.Page, 0, len(rawPages))
var warnings []string var warnings []string
for _, rp := range rawPages { for _, rp := range rawPages {
// The type becomes a directory under wiki/; "../raw" wrote outside it // The type becomes a directory under wiki/; "../raw" wrote outside it
// (PR #147 review). Only a plain lower-case name is a page type. // (PR #147 review). Only a plain lower-case name is a page type.
if !validPageType.MatchString(rp.Type) { if !validPageType.MatchString(rp.Type) || (wing != "" && !layoutType(rp.Type)) {
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type)) warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
continue continue
} }
@@ -28,11 +37,16 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type)) warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
continue continue
} }
out = append(out, buildPage(rp, sourceSlug, date)) out = append(out, buildPage(rp, sourceSlug, date, wing))
} }
return out, warnings return out, warnings
} }
// layoutType reports whether a wing-mode page type has a place in the layout.
// The type comes from the model, so from the clip being ingested: anything else
// would name a directory (wiki/<type>/) of the model's choosing.
func layoutType(t string) bool { return t == "source" || t == "concept" || t == "entity" }
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
func computeSlug(rp RawPage, sourceSlug string) string { func computeSlug(rp RawPage, sourceSlug string) string {
@@ -42,7 +56,7 @@ func computeSlug(rp RawPage, sourceSlug string) string {
return wiki.Slug(rp.Title) return wiki.Slug(rp.Title)
} }
func buildPage(rp RawPage, sourceSlug, date string) wiki.Page { func buildPage(rp RawPage, sourceSlug, date, wing string) wiki.Page {
var slug, dir string var slug, dir string
switch rp.Type { switch rp.Type {
case "source": case "source":
@@ -59,8 +73,14 @@ func buildPage(rp RawPage, sourceSlug, date string) wiki.Page {
dir = "wiki/" + rp.Type dir = "wiki/" + rp.Type
} }
if wing != "" && rp.Type == "source" {
dir = "wiki/" + wing + "/sources"
}
path := dir + "/" + slug + ".md" path := dir + "/" + slug + ".md"
fm := buildFrontmatter(rp, date) fm := buildFrontmatter(rp, date)
if wing != "" && rp.Type == "source" {
fm = addWingFrontmatter(fm, wing, "sources")
}
return wiki.Page{ return wiki.Page{
Path: path, Path: path,
@@ -122,6 +142,12 @@ func buildFrontmatter(rp RawPage, date string) string {
return sb.String() return sb.String()
} }
// addWingFrontmatter inserts wing/hall right after the opening fence of an
// already-built frontmatter block.
func addWingFrontmatter(fm, wing, hall string) string {
return strings.Replace(fm, "---\n", "---\nwing: "+wing+"\nhall: "+hall+"\n", 1)
}
func yamlScalar(s string) string { func yamlScalar(s string) string {
// A raw newline or CR would end the scalar for every line-oriented // A raw newline or CR would end the scalar for every line-oriented
// frontmatter reader and start a new key (PR #147 review: a title of // frontmatter reader and start a new key (PR #147 review: a title of
+251 -23
View File
@@ -5,9 +5,13 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"os" "github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -23,6 +27,11 @@ type Config struct {
Complete CompleteFunc Complete CompleteFunc
ChunkSize int // 0 = no chunking ChunkSize int // 0 = no chunking
Schema string // overrides brain/schema.md when set (useful in tests) Schema string // overrides brain/schema.md when set (useful in tests)
// Wing, when set, writes into wiki/<wing>/{sources,facts}/ and scopes the
// inventory (title resolution, back-references) to that wing alone. Empty
// keeps the retired wiki/{sources,concepts,entities}/ layout.
Wing string
} }
// Result is the outcome of a pipeline run. // Result is the outcome of a pipeline run.
@@ -39,7 +48,16 @@ type Result struct {
// Run ingests content and writes structured wiki pages to brainDir/wiki/. // Run ingests content and writes structured wiki pages to brainDir/wiki/.
// In dry-run mode, pages are returned but not written to disk. // In dry-run mode, pages are returned but not written to disk.
func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryRun bool) (Result, error) { func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryRun bool) (Result, error) {
inventory, err := wiki.LoadInventory(brainDir) if cfg.Wing != "" && brain.Sanitise(cfg.Wing) != cfg.Wing {
return Result{}, fmt.Errorf("invalid wing %q: must be a plain lower-case wing name", cfg.Wing)
}
var inventory map[wiki.PageType][]wiki.Entry
var err error
if cfg.Wing != "" {
inventory, err = wiki.LoadWingInventory(brainDir, cfg.Wing)
} else {
inventory, err = wiki.LoadInventory(brainDir)
}
if err != nil { if err != nil {
return Result{}, fmt.Errorf("load inventory: %w", err) return Result{}, fmt.Errorf("load inventory: %w", err)
} }
@@ -50,6 +68,12 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
} }
sourceSlug := wiki.Slug(source) sourceSlug := wiki.Slug(source)
if cfg.Wing != "" {
var rerr error
if sourceSlug, rerr = resolveSourceSlug(brainDir, cfg.Wing, sourceSlug); rerr != nil {
return Result{}, rerr
}
}
date := time.Now().UTC().Format("2006-01-02") date := time.Now().UTC().Format("2006-01-02")
// Drift check BEFORE any extraction: an unchanged source costs a full LLM // Drift check BEFORE any extraction: an unchanged source costs a full LLM
@@ -57,7 +81,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
// The merge is already idempotent (#103), so this is cost and signal, not // The merge is already idempotent (#103), so this is cost and signal, not
// correctness — which is why it is safe for it to be an early return. // correctness — which is why it is safe for it to be an early return.
bodyHash := hashBody(content) bodyHash := hashBody(content)
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) { if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, cfg.Wing, sourceSlug) {
return Result{Skipped: true, Warnings: []string{ return Result{Skipped: true, Warnings: []string{
fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]), fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]),
}}, nil }}, nil
@@ -68,7 +92,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
// only exists once the model has produced a title. So page-aware // only exists once the model has produced a title. So page-aware
// extraction (#106) is scoped to the source page: it's the one page every // extraction (#106) is scoped to the source page: it's the one page every
// chunk's prompt can be given in advance. // chunk's prompt can be given in advance.
existingPage := readSourcePage(brainDir, sourceSlug) existingPage := readSourcePage(brainDir, cfg.Wing, sourceSlug)
chunks := Chunk(content, cfg.ChunkSize) chunks := Chunk(content, cfg.ChunkSize)
@@ -90,7 +114,19 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
meta.BodyHash = bodyHash meta.BodyHash = bodyHash
applySourceMeta(allRaw, meta) applySourceMeta(allRaw, meta)
return buildAndWrite(ctx, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun) res, err := buildAndWrite(ctx, cfg.Wing, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
if err != nil || cfg.Wing == "" {
return res, err
}
// In a wing every ingest must leave its source page: it is what ties the
// note to the wiki (and carries the hash the drift check reads). Without one
// nothing of the note exists, and the caller would archive it as processed.
for _, p := range res.Pages {
if isSourcePath(p) {
return res, nil
}
}
return res, fmt.Errorf("no source page was written for %q; nothing of the note was ingested (%s)", source, strings.Join(res.Warnings, "; "))
} }
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction // RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
@@ -109,27 +145,38 @@ func RunRaw(ctx context.Context, brainDir, source string, rawPages []RawPage, dr
sourceSlug := wiki.Slug(source) sourceSlug := wiki.Slug(source)
date := time.Now().UTC().Format("2006-01-02") date := time.Now().UTC().Format("2006-01-02")
return buildAndWrite(ctx, rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun) return buildAndWrite(ctx, "", rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
} }
// buildAndWrite runs BuildPages through write for both Run and RunRaw. // buildAndWrite runs BuildPages through write for both Run and RunRaw.
func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) { func buildAndWrite(ctx context.Context, wing string, rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) {
pages, buildWarnings := BuildPages(rawPages, sourceSlug, date) pages, buildWarnings := BuildPagesIn(wing, rawPages, sourceSlug, date)
warnings = append(warnings, buildWarnings...) warnings = append(warnings, buildWarnings...)
resolved := Resolve(pages, inventory) resolved := Resolve(pages, inventory)
canonicalized, linkWarnings := CanonicalizeLinks(resolved, inventory) canonicalized, linkWarnings := CanonicalizeLinks(resolved, inventory)
warnings = append(warnings, linkWarnings...) warnings = append(warnings, linkWarnings...)
withRefs := injectSourceRefs(canonicalized, inventory, brainDir) withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
merged := mergeAll(withRefs) merged := mergeAll(withRefs)
if wing != "" {
var clashWarnings []string
var cerr error
merged, clashWarnings, cerr = dropSlugClashes(brainDir, merged)
if cerr != nil {
return Result{}, cerr
}
warnings = append(warnings, clashWarnings...)
}
if wing != "" {
// The source page carries source_hash, which the drift check trusts: it
// goes last, so a batch that fails midway leaves nothing to hide behind.
sort.SliceStable(merged, func(i, j int) bool { return !isSourcePath(merged[i].Path) && isSourcePath(merged[j].Path) })
}
var written []string var written []string
for _, page := range merged { for _, page := range merged {
if !dryRun { if !dryRun {
dest := filepath.Join(brainDir, filepath.FromSlash(page.Path)) content, conflicts, err := reviseOrCreate(brainDir, page)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
}
content, conflicts, err := reviseOrCreate(dest, page)
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
@@ -147,7 +194,9 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br
if err != nil { if err != nil {
return Result{}, fmt.Errorf("stamp %s: %w", page.Path, err) return Result{}, fmt.Errorf("stamp %s: %w", page.Path, err)
} }
if err := os.WriteFile(dest, []byte(stamped), 0o644); err != nil { // Written through the brain root, never through a symlinked
// directory or page (hyperguild#158).
if err := writeBrainFile(brainDir, page.Path, []byte(stamped)); err != nil {
return Result{}, fmt.Errorf("write %s: %w", page.Path, err) return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
} }
} }
@@ -155,7 +204,11 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br
} }
if !dryRun { if !dryRun {
if err := wiki.RebuildIndex(brainDir, date); err != nil { if wing != "" {
if err := brain.BuildWingIndex(brainDir, wing); err != nil {
warnings = append(warnings, fmt.Sprintf("rebuild wing index: %v", err))
}
} else if err := wiki.RebuildIndex(brainDir, date); err != nil {
warnings = append(warnings, fmt.Sprintf("rebuild index: %v", err)) warnings = append(warnings, fmt.Sprintf("rebuild index: %v", err))
} }
if err := wiki.AppendLog(brainDir, source, written, warnings, date); err != nil { if err := wiki.AppendLog(brainDir, source, written, warnings, date); err != nil {
@@ -188,22 +241,50 @@ 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.
func readSourcePage(brainDir, sourceSlug string) string { // It reads through the brain root, regular files only (hyperguild#157): the
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md")) // content goes into the LLM prompt.
func readSourcePage(brainDir, wing, sourceSlug string) string {
rel := "wiki/sources/" + sourceSlug + ".md"
if wing != "" {
rel = "wiki/" + wing + "/sources/" + sourceSlug + ".md"
}
raw, err := readBrainFile(brainDir, rel)
if err != nil { if err != nil {
return "" return ""
} }
return string(raw) return string(raw)
} }
// writeBrainFile creates or replaces one brain-relative file through the
// brain root (hyperguild#158).
func writeBrainFile(brainDir, rel string, data []byte) error {
bfs, err := brainfs.Open(brainDir)
if err != nil {
return err
}
defer func() { _ = bfs.Close() }()
return bfs.PutRegular(rel, data)
}
// 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.
// //
// Absence always means "extract". That is the fail-safe direction: a missed // Absence always means "extract". That is the fail-safe direction: a missed
// skip costs one extraction, while a wrong skip loses an update silently — and // skip costs one extraction, while a wrong skip loses an update silently — and
// silence is the failure mode this repo keeps paying for. // silence is the failure mode this repo keeps paying for.
func storedSourceHash(brainDir, sourceSlug string) string { func storedSourceHash(brainDir, wing, sourceSlug string) string {
for _, line := range strings.Split(readSourcePage(brainDir, sourceSlug), "\n") { for _, line := range strings.Split(readSourcePage(brainDir, wing, sourceSlug), "\n") {
if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" { if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" {
return strings.TrimSpace(value) return strings.TrimSpace(value)
} }
@@ -230,10 +311,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(dest string, page wiki.Page) (string, []string, error) { func reviseOrCreate(brainDir string, page wiki.Page) (string, []string, error) {
existing, err := os.ReadFile(dest) existing, err := readBrainFile(brainDir, page.Path)
switch { switch {
case os.IsNotExist(err): case errors.Is(err, fs.ErrNotExist):
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)
@@ -267,9 +348,156 @@ See brain/schema.md for the full schema.
` `
func loadSchema(brainDir string) string { func loadSchema(brainDir string) string {
b, err := os.ReadFile(filepath.Join(brainDir, "schema.md")) b, err := readBrainFile(brainDir, "schema.md")
if err != nil { if err != nil {
return defaultSchema return defaultSchema
} }
return strings.TrimSpace(string(b)) return strings.TrimSpace(string(b))
} }
// resolveSourceSlug returns the slug this wing's source page uses. A slug is a
// page's graph identity and must be unique brain-wide, but two wings dropping a
// file of the same name (notes.md, readme.md) is ordinary, so when the plain
// slug is taken by anything but this wing's own source page the wing name is
// appended (talk-homelab, then talk-homelab-2, ...). The choice is stable: a
// re-ingest finds the page it made, because that page is "its own".
func resolveSourceSlug(brainDir, wing, base string) (string, error) {
for n := 1; n <= 100; n++ {
cand := base
switch n {
case 1:
case 2:
cand = base + "-" + wing
default:
cand = fmt.Sprintf("%s-%s-%d", base, wing, n-1)
}
existing, err := wiki.FindSlugs(brainDir, cand)
if err != nil {
return "", fmt.Errorf("check source slug: %w", err)
}
own := "wiki/" + wing + "/sources/" + cand + ".md"
free, hasOwn := true, false
for _, p := range existing {
if p == own {
hasOwn = true
} else {
free = false
}
}
// The wing's own page is its identity: keep using it even if some other
// page shares the slug (that other page is the one dropSlugClashes skips).
if hasOwn {
return cand, nil
}
if free {
return cand, nil
}
}
return "", fmt.Errorf("no free source slug for %q in wing %q", base, wing)
}
// dropSlugClashes removes, from the pages about to be written in a wing, any
// page whose slug belongs to a different page. A slug is the graph identity, so
// two pages must not share one; and a page that shares one cannot be updated
// again, because every later ingest sees the other as its clash.
//
// Two kinds of clash, both by slug, neither failing the ingest (one extracted
// page must not be able to lock a source out of re-ingest):
// - inside the batch (a clip named notes.md whose model output also has an
// entity "Notes"): the source wins over an entity, an entity over a concept;
// - against the brain: an entity or concept whose slug is another page's. The
// SOURCE page is exempt: it is the note's own output, and its slug was
// already chosen free (or its own) by resolveSourceSlug.
//
// A page's own path is never a clash (a shared entity merging into itself).
func dropSlugClashes(brainDir string, pages []wiki.Page) ([]wiki.Page, []string, error) {
rank := func(p wiki.Page) int {
switch {
case isSourcePath(p.Path):
return 0
case strings.HasPrefix(p.Path, "wiki/entities/"):
return 1
case strings.HasPrefix(p.Path, "wiki/concepts/"):
return 2
}
return 3
}
slugOf := func(p wiki.Page) string { return strings.TrimSuffix(filepath.Base(p.Path), ".md") }
// Group by slug; where several pages with different paths want one slug,
// the source wins, then a page that already exists on disk (so its update
// is not lost), then entity over concept.
bySlug := map[string][]wiki.Page{}
for _, p := range pages {
bySlug[slugOf(p)] = append(bySlug[slugOf(p)], p)
}
winner := map[string]wiki.Page{}
for slug, group := range bySlug {
best := group[0]
if distinctPaths(group) > 1 {
existing, err := wiki.FindSlugs(brainDir, slug)
if err != nil {
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
}
onDisk := func(p wiki.Page) int {
for _, e := range existing {
if e == p.Path {
return 0
}
}
return 1
}
for _, p := range group[1:] {
if rank(p) != rank(best) && (rank(p) == 0 || rank(best) == 0) {
if rank(p) < rank(best) {
best = p
}
continue
}
if onDisk(p) < onDisk(best) || (onDisk(p) == onDisk(best) && rank(p) < rank(best)) {
best = p
}
}
}
winner[slug] = best
}
var kept []wiki.Page
var warnings []string
for _, p := range pages {
slug := slugOf(p)
if w := winner[slug]; w.Path != p.Path {
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is also used by %s in this batch", p.Path, slug, w.Path))
continue
}
if rank(p) == 0 {
kept = append(kept, p)
continue
}
existing, err := wiki.FindSlugs(brainDir, slug)
if err != nil {
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
}
clash := ""
for _, e := range existing {
if e != p.Path {
clash = e
break
}
}
if clash != "" {
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is already used by %s", p.Path, slug, clash))
continue
}
kept = append(kept, p)
}
return kept, warnings, nil
}
func distinctPaths(pages []wiki.Page) int {
seen := map[string]bool{}
for _, p := range pages {
seen[p.Path] = true
}
return len(seen)
}
+30 -7
View File
@@ -2,7 +2,6 @@
package pipeline package pipeline
import ( import (
"os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -24,7 +23,7 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
var sourceContent string var sourceContent string
for _, p := range pages { for _, p := range pages {
if strings.HasPrefix(p.Path, "wiki/sources/") && if isSourcePath(p.Path) &&
strings.TrimSuffix(filepath.Base(p.Path), ".md") == sourceSlug { strings.TrimSuffix(filepath.Base(p.Path), ".md") == sourceSlug {
sourceContent = p.Content sourceContent = p.Content
break break
@@ -36,7 +35,7 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
bySlug := make(map[string]int, len(pages)) bySlug := make(map[string]int, len(pages))
for i, p := range pages { for i, p := range pages {
if !strings.HasPrefix(p.Path, "wiki/sources/") { if !isSourcePath(p.Path) {
bySlug[strings.TrimSuffix(filepath.Base(p.Path), ".md")] = i bySlug[strings.TrimSuffix(filepath.Base(p.Path), ".md")] = i
} }
} }
@@ -53,13 +52,13 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
if !ok { if !ok {
continue continue
} }
diskPath := filepath.Join(brainDir, "wiki", string(pt), slug+".md") path := entryPath(slug, pt, inventory)
b, err := os.ReadFile(diskPath) b, err := readBrainFile(brainDir, path)
if err != nil { if err != nil {
continue continue
} }
page := wiki.Page{ page := wiki.Page{
Path: "wiki/" + string(pt) + "/" + slug + ".md", Path: path,
Content: string(b), Content: string(b),
} }
pages = append(pages, addSourceRef(page, sourceRef)) pages = append(pages, addSourceRef(page, sourceRef))
@@ -90,7 +89,7 @@ func extractWikilinks(content string) map[string]bool {
// findSourcePage returns the slug and title of the first wiki/sources/ page in pages. // findSourcePage returns the slug and title of the first wiki/sources/ page in pages.
func findSourcePage(pages []wiki.Page) (slug, title string, found bool) { func findSourcePage(pages []wiki.Page) (slug, title string, found bool) {
for _, p := range pages { for _, p := range pages {
if strings.HasPrefix(p.Path, "wiki/sources/") { if isSourcePath(p.Path) {
slug = strings.TrimSuffix(filepath.Base(p.Path), ".md") slug = strings.TrimSuffix(filepath.Base(p.Path), ".md")
title = extractTitle(p.Content) title = extractTitle(p.Content)
if title == "" { if title == "" {
@@ -113,3 +112,27 @@ func findInInventory(slug string, inventory map[wiki.PageType][]wiki.Entry) (wik
} }
return "", false return "", false
} }
// isSourcePath reports whether path is a source page in either layout:
// wiki/sources/<slug>.md or wiki/<wing>/sources/<slug>.md.
func isSourcePath(path string) bool {
parts := strings.Split(filepath.ToSlash(path), "/")
switch {
case len(parts) == 3 && parts[0] == "wiki" && parts[1] == "sources":
return true
case len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources":
return true
}
return false
}
// entryPath is the brain-relative path of an inventory page: the recorded Path
// for a wing-layout entry, else the retired wiki/<type>/<slug>.md.
func entryPath(slug string, pt wiki.PageType, inventory map[wiki.PageType][]wiki.Entry) string {
for _, e := range inventory[pt] {
if e.Slug == slug && e.Path != "" {
return e.Path
}
}
return "wiki/" + string(pt) + "/" + slug + ".md"
}
+25 -1
View File
@@ -32,7 +32,14 @@ func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []w
out := make([]wiki.Page, 0, len(proposed)) out := make([]wiki.Page, 0, len(proposed))
for _, page := range proposed { for _, page := range proposed {
pt := pageTypeFromPath(page.Path) if isWingSourcePath(page.Path) {
// In a wing the source's path is its filename-derived slug. Matching
// by TITLE would merge two different notes the model happened to
// title alike ("Home", "Untitled") into one page.
out = append(out, page)
continue
}
pt := pageTypeOf(page)
title := extractTitle(page.Content) title := extractTitle(page.Content)
k := key{pt: pt, normalized: normalizeTitle(title)} k := key{pt: pt, normalized: normalizeTitle(title)}
if canonicalSlug, ok := lookup[k]; ok { if canonicalSlug, ok := lookup[k]; ok {
@@ -55,6 +62,17 @@ func normalizeTitle(s string) string {
return strings.Join(strings.Fields(s), " ") return strings.Join(strings.Fields(s), " ")
} }
// pageTypeOf is the page's type: from its directory in the retired layout, or
// "sources" for wiki/<wing>/sources/<slug>.md. Concepts and entities are always
// in their shared directories.
func pageTypeOf(p wiki.Page) wiki.PageType {
parts := strings.Split(filepath.ToSlash(p.Path), "/")
if len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources" {
return wiki.PageTypeSource
}
return pageTypeFromPath(p.Path)
}
// pageTypeFromPath extracts the wiki.PageType from a path like "wiki/entities/foo.md". // pageTypeFromPath extracts the wiki.PageType from a path like "wiki/entities/foo.md".
func pageTypeFromPath(path string) wiki.PageType { func pageTypeFromPath(path string) wiki.PageType {
parts := strings.Split(filepath.ToSlash(path), "/") parts := strings.Split(filepath.ToSlash(path), "/")
@@ -86,3 +104,9 @@ func extractTitle(content string) string {
} }
return "" return ""
} }
// isWingSourcePath reports whether path is wiki/<wing>/sources/<slug>.md.
func isWingSourcePath(path string) bool {
parts := strings.Split(filepath.ToSlash(path), "/")
return len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources"
}
+217
View File
@@ -0,0 +1,217 @@
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(), "[[s|S]]", "positive control: the regular page gets the source ref")
brainfstest.AssertNoLeakFrom(t, page, inside, target, 3*time.Second, read)
}
// hyperguild#158: BackfillRefs read a regular concept page, a symlink could
// be swapped in, and os.WriteFile then followed it and overwrote the link's
// target. The target is not restored between rounds here, so any
// write-through shows.
func TestBackfillRefsNeverWritesThroughAPageSwappedForASymlink(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")
require.NoError(t, os.WriteFile(target, []byte("untouched"), 0o644))
inside := "---\ntitle: C\n---\nbody\n"
require.NoError(t, os.WriteFile(page, []byte(inside), 0o644))
brainfstest.StartSwap(t, page, inside, target, false)
ok := 0
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
if _, err := BackfillRefs(context.Background(), brain); err == nil {
ok++
}
}
got, _ := os.ReadFile(target)
assert.Equal(t, "untouched", string(got), "BackfillRefs wrote through a swapped-in symlink")
assert.Positive(t, ok, "positive control: some backfills ran")
}
// hyperguild#158: buildAndWrite wrote each page with os.WriteFile by path, so
// a page read as regular and swapped for a symlink before the write was
// written through. The target is not restored between rounds, so any
// write-through shows.
func TestBuildAndWriteNeverWritesThroughAPageSwappedForASymlink(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "wiki/entities", "raw")
page := filepath.Join(brain, "wiki", "concepts", "zq-concept.md")
target := filepath.Join(brain, "raw", "secret.md")
require.NoError(t, os.WriteFile(target, []byte("untouched"), 0o644))
inside := "---\ntitle: Zq Concept\n---\nbody\n"
require.NoError(t, os.WriteFile(page, []byte(inside), 0o644))
pages := []RawPage{{Title: "Zq Concept", Type: "concept", Content: "## About\n\nmore\n"}}
brainfstest.StartSwap(t, page, inside, target, false)
ok := 0
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
if _, err := RunRaw(context.Background(), brain, "zq source", pages, false); err == nil {
ok++
}
}
got, _ := os.ReadFile(target)
assert.Equal(t, "untouched", string(got), "buildAndWrite wrote through a swapped-in symlink")
assert.Positive(t, ok, "positive control: some runs returned without error")
}
@@ -0,0 +1,396 @@
package pipeline
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func wingCfg(wing string, pages ...RawPage) Config {
return Config{
Wing: wing,
Schema: "# Schema",
Complete: func(context.Context, string, string) (string, error) {
return mustJSON(pages), nil
},
}
}
func sourceEntityConcept() []RawPage {
return []RawPage{
{Title: "Dark Factory Talk", Type: "source", Subtype: "video", Domain: "ai",
Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n\n## Concepts Introduced or Reinforced\n\n[[Dark Factory]]\n"},
{Title: "Jev", Type: "entity", Subtype: "tool", Domain: "ai",
Content: "## Definition\n\nA decision model.\n\n## Sources\n"},
{Title: "Dark Factory", Type: "concept", Domain: "ai",
Content: "## Definition\n\nA fully automated build line.\n\n## Sources\n"},
}
}
func exists(dir, rel string) bool {
_, err := os.Stat(filepath.Join(dir, rel))
return err == nil
}
func read(t *testing.T, dir, rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, rel))
require.NoError(t, err)
return string(b)
}
// With a wing, the SOURCE belongs to the wing; concepts and entities are the
// shared cross-wing layer (wiki/concepts, wiki/entities) because their slug is
// the graph's global identity and they are cited from many wings.
func TestRun_WithAWingTheSourceIsPerWingAndConceptsEntitiesAreShared(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body of the talk", "dark-factory-talk", false)
require.NoError(t, err)
src := read(t, dir, "wiki/web-clip/sources/dark-factory-talk.md")
assert.Contains(t, src, "wing: web-clip")
assert.Contains(t, src, "hall: sources")
assert.True(t, exists(dir, "wiki/entities/jev.md"))
assert.True(t, exists(dir, "wiki/concepts/dark-factory.md"))
assert.NotContains(t, read(t, dir, "wiki/entities/jev.md"), "wing:", "a shared page belongs to no single wing")
assert.False(t, exists(dir, "wiki/web-clip/facts"), "no per-wing copy of a shared page")
assert.False(t, exists(dir, "wiki/sources"), "the retired source layout is not used")
}
func TestRun_WithAWingTheSharedPageLinksBackToTheSource(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "dark-factory-talk", false)
require.NoError(t, err)
assert.Contains(t, read(t, dir, "wiki/entities/jev.md"), "[[dark-factory-talk|Dark Factory Talk]]")
}
func TestRun_TheSameEntityCitedFromTwoWingsIsOnePageWithBothSources(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "first body", "talk-one", false)
require.NoError(t, err)
// A wing must already exist to be named; create it the way a note would.
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
again := []RawPage{
{Title: "Second Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nMore.\n\n## Entities Mentioned\n\n[[The Jev]]\n"},
{Title: "The Jev", Type: "entity", Subtype: "tool", Content: "## Definition\n\nStill a decision model.\n\n## Sources\n"},
}
_, err = Run(context.Background(), wingCfg("homelab", again...), dir, "second body", "talk-two", false)
require.NoError(t, err)
files, err := filepath.Glob(filepath.Join(dir, "wiki/entities/*.md"))
require.NoError(t, err)
assert.Len(t, files, 1, "one entity page, matched by title with the leading article ignored")
jev := read(t, dir, "wiki/entities/jev.md")
assert.Contains(t, jev, "talk-one")
assert.Contains(t, jev, "talk-two")
assert.False(t, exists(dir, "wiki/homelab/facts/jev.md"), "no second copy in the second wing")
}
// A source slug is the graph's identity too: a second wing must not take over
// the slug of a page that lives elsewhere. Two wings dropping a file of the same
// name (notes.md, readme.md) is ordinary, so the second source gets a distinct,
// stable slug instead of failing.
func TestRun_ASourceWhoseSlugIsTakenElsewhereGetsADistinctStableSlug(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body", "talk", false)
require.NoError(t, err)
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"), "the first wing's page is untouched")
assert.True(t, exists(dir, "wiki/homelab/sources/talk-homelab.md"), "the second gets a wing-qualified slug")
assert.False(t, exists(dir, "wiki/homelab/sources/talk.md"))
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body, edited", "talk", false)
require.NoError(t, err)
files, _ := filepath.Glob(filepath.Join(dir, "wiki/homelab/sources/*.md"))
assert.Len(t, files, 1, "re-ingesting finds the same page, it does not make talk-homelab-2")
}
func TestRun_ASourceWhoseSlugIsTakenByARetiredPageOrASharedPageGetsADistinctSlug(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/sources"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/sources/talk.md"), []byte("---\ntitle: Talk\n---\nold"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/other.md"), []byte("---\ntitle: Other\n---\nx"), 0o644))
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
require.NoError(t, err)
assert.True(t, exists(dir, "wiki/web-clip/sources/talk-web-clip.md"))
assert.Equal(t, "---\ntitle: Talk\n---\nold", read(t, dir, "wiki/sources/talk.md"), "the retired page is untouched")
}
// The other direction: a model-extracted entity or concept whose slug is a
// source's slug in some wing would lock that source out of re-ingest. The page
// is skipped with a warning; the ingest itself succeeds.
func TestRun_AnEntityWhoseSlugIsASourceElsewhereIsSkippedNotWritten(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", []RawPage{
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nA clip titled Jev.\n"},
}...), dir, "body", "jev", false)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
res, err := Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "another body", "other-talk", false)
require.NoError(t, err)
assert.False(t, exists(dir, "wiki/entities/jev.md"), "the entity would take the source's slug")
assert.Contains(t, strings.Join(res.Warnings, "\n"), "jev")
_, err = Run(context.Background(), wingCfg("web-clip", []RawPage{
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nEdited.\n"},
}...), dir, "body, edited", "jev", false)
require.NoError(t, err, "the first wing can still re-ingest its own source")
}
// The type comes from the model, so from the clip: in a wing it may only be one
// of the three the layout has a place for.
func TestRun_WithAWingAModelChosenTypeCannotWriteOutsideTheLayout(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/alpha/facts"), 0o755))
pages := append(sourceEntityConcept(),
RawPage{Title: "Planted", Type: "sources", Content: "## x\n\nplanted\n"},
RawPage{Title: "Loose", Type: "alpha", Content: "## x\n\nloose\n"},
RawPage{Title: "Sneaky", Type: "homelab", Content: "## x\n\nsneaky\n"})
res, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
require.NoError(t, err)
assert.False(t, exists(dir, "wiki/sources/planted.md"))
assert.False(t, exists(dir, "wiki/alpha/loose.md"))
assert.False(t, exists(dir, "wiki/homelab"))
assert.Contains(t, strings.Join(res.Warnings, "\n"), "invalid type")
}
func TestRun_ReIngestingTheSameSourceIntoItsOwnWingIsNotACollision(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body one", "talk", false)
require.NoError(t, err)
_, err = Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body two, edited", "talk", false)
require.NoError(t, err)
}
func TestRun_WithAWingRejectsANameThatIsNotAPlainWing(t *testing.T) {
for _, bad := range []string{"../escape", "a/b", "Radar", "has space", "."} {
t.Run(bad, func(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg(bad, sourceEntityConcept()...), dir, "body", "talk", false)
require.Error(t, err)
assert.Len(t, listAll(t, dir), 1, "nothing is written for a rejected wing (only the temp dir itself exists)")
})
}
}
func listAll(t *testing.T, dir string) []string {
t.Helper()
var out []string
_ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, _ error) error { out = append(out, p); return nil })
return out
}
// Title resolution can mask a wrong slug choice at the Run level, so the
// stability of the choice is pinned directly: the page this wing made earlier
// under the qualified slug is "its own" and is found again.
func TestResolveSourceSlug(t *testing.T) {
dir := t.TempDir()
for _, rel := range []string{
"wiki/web-clip/sources/talk.md",
"wiki/homelab/sources/talk-homelab.md",
"wiki/entities/kube.md",
} {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("---\ntitle: x\n---\n"), 0o644))
}
tests := []struct {
name, wing, base, want string
}{
{"free slug is used as is", "homelab", "fresh", "fresh"},
{"own page under the plain slug", "web-clip", "talk", "talk"},
{"taken elsewhere: wing-qualified", "tapir", "talk", "talk-tapir"},
{"own earlier qualified page is found again", "homelab", "talk", "talk-homelab"},
{"taken by a shared page", "tapir", "kube", "kube-tapir"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveSourceSlug(dir, tt.wing, tt.base)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestResolveSourceSlug_AFurtherClashGetsANumber(t *testing.T) {
dir := t.TempDir()
for _, rel := range []string{"wiki/a/sources/talk.md", "wiki/entities/talk-b.md"} {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("x"), 0o644))
}
got, err := resolveSourceSlug(dir, "b", "talk")
require.NoError(t, err)
assert.Equal(t, "talk-b-2", got)
}
// From the second review of #211: slug clashes INSIDE one batch. dropSlugClashes
// only looked at what was already on disk, so a source and an entity extracted
// together with one slug were both written, and neither could ever be updated.
func TestRun_ASourceAndAnEntityWithOneSlugInTheSameBatchWriteOnlyTheSource(t *testing.T) {
dir := t.TempDir()
pages := []RawPage{
{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nfirst\n"},
{Title: "Notes", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity named like its source\n"},
}
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body one", "notes", false)
require.NoError(t, err)
assert.True(t, exists(dir, "wiki/homelab/sources/notes.md"))
assert.False(t, exists(dir, "wiki/entities/notes.md"), "two pages must not share a slug")
assert.Contains(t, strings.Join(res.Warnings, "\n"), "notes")
pages[0].Content = "## Summary\n\nfirst\n\n## Key Claims\n\n- second\n"
_, err = Run(context.Background(), wingCfg("homelab", pages...), dir, "body two, changed", "notes", false)
require.NoError(t, err)
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "second", "the source can still be updated")
}
func TestRun_AConceptAndAnEntityWithOneSlugInTheSameBatchWriteOne(t *testing.T) {
dir := t.TempDir()
pages := []RawPage{
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"},
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n"},
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
}
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body", "talk", false)
require.NoError(t, err)
written := 0
for _, rel := range []string{"wiki/concepts/foo.md", "wiki/entities/foo.md"} {
if exists(dir, rel) {
written++
}
}
assert.Equal(t, 1, written, "one slug, one page")
assert.Contains(t, strings.Join(res.Warnings, "\n"), "foo")
}
// A brain that already holds the bad pair (written before this fix) must not
// lock the source out: the source page is the note's own output and is never
// the one skipped.
func TestRun_AnExistingSourceEntityPairDoesNotLockTheSourceOut(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/sources"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/homelab/sources/notes.md"),
[]byte("---\ntitle: 'Notes'\ntype: 'note'\nwing: homelab\nhall: sources\naliases:\n - 'Notes'\n---\n\n## Summary\n\nold\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/notes.md"),
[]byte("---\ntitle: 'Notes'\naliases:\n - 'Notes'\n---\n\n## Definition\n\nstale entity\n"), 0o644))
pages := []RawPage{{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nnew text\n\n## Key Claims\n\n- fresh\n"}}
_, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "changed body", "notes", false)
require.NoError(t, err)
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "fresh", "the source took the update")
assert.Contains(t, read(t, dir, "wiki/entities/notes.md"), "stale entity", "the entity is untouched")
}
// Pass 3 of #211: a note must never leave the inbox as "processed" when nothing
// of it was written.
func TestRun_WithAWingAnIngestThatWritesNoSourcePageIsAnError(t *testing.T) {
tests := []struct {
name string
pages []RawPage
prep func(t *testing.T, dir string)
}{
{"the model returned nothing", nil, nil},
{"only pages of a type the layout has no place for", []RawPage{{Title: "Claim", Type: "claim", Content: "## x\n\ny\n"}}, nil},
{"only an entity, and its slug is taken by a source", []RawPage{{Title: "S1", Type: "entity", Subtype: "tool", Content: "## Definition\n\nx\n"}},
func(t *testing.T, dir string) {
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/web-clip/sources"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/web-clip/sources/s1.md"), []byte("---\ntitle: 'S1'\n---\nx"), 0o644))
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if tt.prep != nil {
tt.prep(t, dir)
}
res, err := Run(context.Background(), wingCfg("web-clip", tt.pages...), dir, "a note body", "other-note", false)
require.Error(t, err, "nothing was ingested; the caller must not archive it as done")
assert.Empty(t, res.Pages)
assert.Contains(t, err.Error(), "no source page")
})
}
}
func TestRun_WithAWingAnUnchangedSourceIsStillASkipNotAnError(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
require.NoError(t, err)
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
require.NoError(t, err)
assert.True(t, res.Skipped)
}
// Title resolution must not merge two different notes into one source page just
// because the model titled both "Home": in a wing the source path is already
// deterministic (the filename), so the title must not redirect it.
func TestRun_WithAWingTwoNotesTheModelTitlesAlikeKeepTheirOwnSourcePages(t *testing.T) {
dir := t.TempDir()
home := func(body string) []RawPage {
return []RawPage{{Title: "Home", Type: "source", Subtype: "article", Content: "## Summary\n\n" + body + "\n"}}
}
_, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
require.NoError(t, err)
_, err = Run(context.Background(), wingCfg("web-clip", home("note B text")...), dir, "body B", "note-b", false)
require.NoError(t, err)
assert.True(t, exists(dir, "wiki/web-clip/sources/note-a.md"))
assert.True(t, exists(dir, "wiki/web-clip/sources/note-b.md"), "note B has its own page")
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-a.md"), "note A text")
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-b.md"), "note B text")
res, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
require.NoError(t, err)
assert.True(t, res.Skipped, "note A's hash was not overwritten by note B")
}
// Both a concept and an entity named "Foo" arrive and the CONCEPT already exists
// on disk: the page that exists wins the tie, so its source reference is added.
func TestRun_ABatchTieBetweenAConceptAndAnEntityPrefersThePageAlreadyOnDisk(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/concepts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/concepts/foo.md"),
[]byte("---\ntitle: 'Foo'\naliases:\n - 'Foo'\n---\n\n## Definition\n\nexisting concept\n\n## Sources\n"), 0o644))
pages := []RawPage{
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n\n## Concepts Introduced or Reinforced\n\n[[Foo]]\n"},
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n\n## Sources\n"},
}
_, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
require.NoError(t, err)
assert.False(t, exists(dir, "wiki/entities/foo.md"))
assert.Contains(t, read(t, dir, "wiki/concepts/foo.md"), "talk", "the existing concept got the new source reference")
}
// Pass 4 of #211: the source page carries the hash the drift check reads. If it
// is written FIRST and a later page fails to write, the next ingest of the same
// content is skipped and the rest of the batch is lost for good. Written LAST,
// a failed batch leaves no hash and is simply redone.
func TestRun_WithAWingAFailedBatchLeavesNoSourcePageToHideBehind(t *testing.T) {
dir := t.TempDir()
// An entity path that cannot be written: a directory where the page goes.
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities/jev.md"), 0o755))
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
require.Error(t, err, "the entity write fails")
assert.False(t, exists(dir, "wiki/web-clip/sources/talk.md"),
"the source page (and its hash) must not exist when the batch did not complete")
require.NoError(t, os.RemoveAll(filepath.Join(dir, "wiki/entities/jev.md")))
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
require.NoError(t, err)
assert.False(t, res.Skipped, "the retry is a real ingest, not a hash skip")
assert.True(t, exists(dir, "wiki/entities/jev.md"))
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"))
}
@@ -28,7 +28,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os"
"regexp" "regexp"
"strings" "strings"
@@ -102,25 +101,6 @@ func Apply(content string, s Stamp) (string, error) {
return out, nil return out, nil
} }
// ApplyFile applies the stamp to the note at absPath in place.
func ApplyFile(absPath string, s Stamp) error {
b, err := os.ReadFile(absPath)
if err != nil {
return fmt.Errorf("read for provenance: %w", err)
}
out, err := Apply(string(b), s)
if err != nil {
return err
}
if out == string(b) {
return nil
}
if err := os.WriteFile(absPath, []byte(out), 0o644); err != nil {
return fmt.Errorf("write provenance: %w", err)
}
return nil
}
// plainGroups keeps the provably plain key groups of a frontmatter block. // plainGroups keeps the provably plain key groups of a frontmatter block.
func plainGroups(block string) string { func plainGroups(block string) string {
lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n") lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")
+74 -9
View File
@@ -12,6 +12,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
) )
// hyperguild#157 option 1: search walked and read the brain by path, so a // hyperguild#157 option 1: search walked and read the brain by path, so a
@@ -93,16 +95,20 @@ func TestQueryDoesNotFollowASymlinkedWingUnderAWingAndHallRoot(t *testing.T) {
// bypass of readOpened hung Query after 5 iterations). // bypass of readOpened hung Query after 5 iterations).
// startSwap runs swapInto until the test ends and waits for it before // startSwap runs swapInto until the test ends and waits for it before
// t.TempDir's cleanup (hyperguild#162). // t.TempDir's cleanup (hyperguild#162).
func startSwap(t *testing.T, path, outside string, fifo bool) { // It returns the count of symlinks or FIFOs actually renamed into path
// (hyperguild#177: a swapper whose every swap fails tests nothing).
func startSwap(t *testing.T, path, outside string, fifo bool) *atomic.Int64 {
t.Helper() t.Helper()
var stop atomic.Bool var stop atomic.Bool
var swaps atomic.Int64
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
go func() { defer wg.Done(); swapInto(path, outside, fifo, &stop) }() go func() { defer wg.Done(); swapInto(path, outside, fifo, &stop, &swaps) }()
t.Cleanup(func() { stop.Store(true); wg.Wait() }) t.Cleanup(func() { stop.Store(true); wg.Wait() })
return &swaps
} }
func swapInto(path, outside string, fifo bool, stop *atomic.Bool) { func swapInto(path, outside string, fifo bool, stop *atomic.Bool, swaps *atomic.Int64) {
tmp := path + ".swap" tmp := path + ".swap"
// Writers stay open until the swapper exits, so a reader parked on a // Writers stay open until the swapper exits, so a reader parked on a
// FIFO stays parked (closing one would hand the reader EOF and hide the // FIFO stays parked (closing one would hand the reader EOF and hide the
@@ -115,33 +121,46 @@ func swapInto(path, outside string, fifo bool, stop *atomic.Bool) {
}() }()
for i := 0; !stop.Load(); i++ { for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp) _ = os.Remove(tmp)
var made error
switch { switch {
case i%2 == 0: case i%2 == 0:
_ = os.WriteFile(tmp, []byte("# r\n\nzqinside "+"zqracenote"), 0o644) _ = os.WriteFile(tmp, []byte("# r\n\nzqinside "+"zqracenote"), 0o644)
case fifo: case fifo:
if err := syscall.Mkfifo(tmp, 0o644); err == nil { if made = syscall.Mkfifo(tmp, 0o644); made == nil {
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil { if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
writers = append(writers, w) writers = append(writers, w)
} }
} }
default: default:
_ = os.Symlink(outside, tmp) made = os.Symlink(outside, tmp)
}
if err := os.Rename(tmp, path); err == nil && made == nil && i%2 == 1 {
swaps.Add(1)
} }
_ = os.Rename(tmp, path)
} }
} }
func TestQueryLeaksNothingUnderASymlinkSwapRace(t *testing.T) { func TestQueryLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
brain, facts, outside := searchBrain(t) brain, facts, outside := searchBrain(t)
startSwap(t, filepath.Join(facts, "r.md"), outside, false) swaps := startSwap(t, filepath.Join(facts, "r.md"), outside, false)
leaks, iters := 0, 0 leaks, iters, insideReads := 0, 0, 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ { for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
rs, _ := Query(brain, QueryOptions{Query: sentinel157, Limit: 10}) rs, err := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
require.NoError(t, err)
leaks += leaked(rs) leaks += leaked(rs)
// Positive control (hyperguild#177): a Query that always errors
// returns nothing and so never leaks.
rs, err = Query(brain, QueryOptions{Query: "zqracenote", Limit: 10})
require.NoError(t, err)
if len(rs) > 0 {
insideReads++
}
} }
if leaks > 0 { if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters) t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
} }
require.Positive(t, swaps.Load(), "no symlink was swapped in: the race did not run")
require.Positive(t, insideReads, "no Query returned the inside note")
} }
func TestQueryDoesNotHangUnderAFIFOSwapRace(t *testing.T) { func TestQueryDoesNotHangUnderAFIFOSwapRace(t *testing.T) {
@@ -172,3 +191,49 @@ func TestHydrateRefusesASymlink(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.NotContains(t, r.Excerpt, sentinel157) assert.NotContains(t, r.Excerpt, sentinel157)
} }
// hyperguild#171: os.Root refuses links that leave the brain on its own, so
// the outside-target cases above would stay green for a caller that read
// without brainfs's Lstat and SameFile checks. These targets are inside the
// brain (raw/, which is not a search root).
func inBrainTarget(t *testing.T, brain string) string {
t.Helper()
return brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
}
func TestQueryDoesNotReturnAnInBrainSymlinkTargetDuringASampledSwapRace(t *testing.T) {
brain, facts, _ := searchBrain(t)
target := inBrainTarget(t, brain)
read := func() string {
// Both terms, so the regular note is a possible hit: a reader that
// never returns the inside note would pass vacuously (#177 floor).
rs, _ := Query(brain, QueryOptions{Query: brainfstest.Sentinel + " zqinside", Limit: 10})
s := ""
for _, r := range rs {
s += r.Title + r.Excerpt
}
return s
}
brainfstest.AssertNoLeakFrom(t, filepath.Join(facts, "r.md"), "# r\n\nzqinside", target, 1500*time.Millisecond, read)
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
assert.NotContains(t, read(), brainfstest.Sentinel, "static in-brain link")
}
func TestHydrateRefusesAnInBrainSymlinkAndDoesNotReturnItsTargetDuringASampledSwapRace(t *testing.T) {
brain, facts, _ := searchBrain(t)
target := inBrainTarget(t, brain)
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
r, err := hydrate(brain, "wiki/w/facts/inlink.md")
assert.Error(t, err)
assert.NotContains(t, r.Excerpt+r.Title, brainfstest.Sentinel)
p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("# r\n\nzqinside"), 0o644))
r, err = hydrate(brain, "wiki/w/facts/r.md")
require.NoError(t, err)
assert.Contains(t, r.Excerpt, "zqinside", "positive control: a regular note hydrates")
brainfstest.AssertNoLeakFrom(t, p, "# r\n\nzqinside", target, 1500*time.Millisecond, func() string {
r, _ := hydrate(brain, "wiki/w/facts/r.md")
return r.Title + r.Excerpt
})
}
+177
View File
@@ -0,0 +1,177 @@
package session
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
// session_log passed the caller's session_id straight into
// filepath.Join(sessionsDir, id+".jsonl") and opened it O_CREATE|O_APPEND:
// path segments in the id created or appended files anywhere the pod could
// write. The id is now a plain name, and the open does not follow a final
// symlink.
func TestAppendRefusesSessionIDsThatAreNotPlainNames(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "sessions")
for _, id := range []string{"../x", "a/b", "/abs", ".", "..", ".hidden", "", "a\\b", "a\x00b"} {
assert.Error(t, Append(dir, id, Entry{SessionID: id}), "%q", id)
}
ents, _ := os.ReadDir(root)
for _, e := range ents {
assert.Equal(t, "sessions", e.Name(), "nothing created beside the sessions dir")
}
for _, id := range []string{"1557b2e1-bf0d-5c4f-bbe3-0754d087e356", "_routing", "2026-05-05-infra.plan", "0"} {
require.NoError(t, Append(dir, id, Entry{SessionID: id}), "positive control: %q", id)
}
}
func TestAppendDoesNotWriteThroughASymlinkedLog(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "sessions")
require.NoError(t, os.MkdirAll(dir, 0o755))
victim := filepath.Join(root, "victim.md")
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "victim.md"), filepath.Join(dir, "s.jsonl")))
assert.Error(t, Append(dir, "s", Entry{SessionID: "s"}))
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched\n", string(got))
}
func TestReadRefusesSessionIDsThatAreNotPlainNames(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "x.jsonl"), []byte(`{"session_id":"x"}`+"\n"), 0o644))
_, err := Read(filepath.Join(root, "sessions"), "../x")
assert.Error(t, err)
}
// A name that starts plainly and then climbs out with ".." is exactly what the
// ban on "/" in the id's tail stops: filepath.Join cleans "a/../../x" to a
// path beside the sessions dir without needing sessions/a to exist. The
// assertion is on the refusal itself (ErrInvalidID), not on any error.
func TestAppendRefusesAnIDThatClimbsOutAfterAPlainPrefix(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "sessions")
require.NoError(t, os.MkdirAll(dir, 0o755))
for _, id := range []string{"a/../../escape", "a/../../../escape", "a/../b"} {
err := Append(dir, id, Entry{SessionID: id})
assert.ErrorIs(t, err, ErrInvalidID, "%q", id)
}
_, err := os.Lstat(filepath.Join(root, "escape.jsonl"))
assert.True(t, errors.Is(err, os.ErrNotExist), "nothing created beside the sessions dir")
_, err = os.Lstat(filepath.Join(dir, "b.jsonl"))
assert.True(t, errors.Is(err, os.ErrNotExist), "no in-dir alias created either")
}
// Append does no Lstat type check, so O_NONBLOCK is the only thing that keeps
// a FIFO named <id>.jsonl with no reader from blocking session_log forever.
func TestAppendReturnsPromptlyOnAFIFOLogWithNoReader(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sessions")
require.NoError(t, os.MkdirAll(dir, 0o755))
fifo := filepath.Join(dir, "s.jsonl")
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
done := make(chan error, 1)
go func() { done <- Append(dir, "s", Entry{SessionID: "s"}) }()
select {
case err := <-done:
assert.Error(t, err, "a FIFO with no reader is refused, not written")
case <-time.After(3 * time.Second):
// Unblock the stuck open so the goroutine and TempDir cleanup finish.
if r, err := os.OpenFile(fifo, os.O_RDONLY|syscall.O_NONBLOCK, 0); err == nil {
defer r.Close() //nolint:errcheck
}
<-done
t.Fatal("Append blocked on a FIFO log with no reader")
}
}
// hyperguild#173: session_log followed a symlinked sessions dir.
func TestAppendRefusesASymlinkedSessionsDir(t *testing.T) {
root := t.TempDir()
elsewhere := filepath.Join(root, "elsewhere")
require.NoError(t, os.MkdirAll(elsewhere, 0o755))
dir := filepath.Join(root, "sessions")
require.NoError(t, os.Symlink("elsewhere", dir))
assert.Error(t, Append(dir, "s", Entry{SessionID: "s"}))
ents, _ := os.ReadDir(elsewhere)
assert.Empty(t, ents, "a log was created through the symlinked dir")
}
// The sessions dir is swapped (RENAME_EXCHANGE) with a symlink to a
// directory outside the brain while Append runs. Append opens through an
// os.Root on the sessions dir's parent, so a swap can redirect it only
// inside that parent (the #167 residual), never outside it.
func TestAppendNeverCreatesALogOutsideTheParentUnderADirSwapRace(t *testing.T) {
brain := t.TempDir()
outside := t.TempDir()
dir := filepath.Join(brain, "sessions")
other := filepath.Join(brain, "sessions.link")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.Symlink(outside, other))
var stop atomic.Bool
done := make(chan struct{})
go func() {
defer close(done)
for !stop.Load() {
_ = unix.Renameat2(unix.AT_FDCWD, dir, unix.AT_FDCWD, other, unix.RENAME_EXCHANGE)
}
}()
ok := 0
for i := 0; i < 20000; i++ {
if Append(dir, fmt.Sprintf("s%d", i), Entry{SessionID: "s"}) == nil {
ok++
}
}
stop.Store(true)
<-done
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents, "logs were created outside the brain")
assert.Positive(t, ok, "positive control: appends to the real dir succeed")
}
// The log file is swapped between a regular file and a symlink to another
// file in the brain while Append runs: Append writes only after confirming
// the path is the regular file it opened, so the other file never grows.
func TestAppendNeverWritesThroughALogSwappedForASymlink(t *testing.T) {
brain := t.TempDir()
dir := filepath.Join(brain, "sessions")
require.NoError(t, os.MkdirAll(dir, 0o755))
victim := filepath.Join(brain, "victim.md")
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
log := filepath.Join(dir, "s.jsonl")
var stop atomic.Bool
done := make(chan struct{})
go func() {
defer close(done)
tmp := log + ".swap"
for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp)
if i%2 == 0 {
_ = os.WriteFile(tmp, nil, 0o644)
} else {
_ = os.Symlink(filepath.Join("..", "victim.md"), tmp)
}
_ = os.Rename(tmp, log)
}
}()
ok := 0
for i := 0; i < 20000; i++ {
if Append(dir, "s", Entry{SessionID: "s"}) == nil {
ok++
}
}
stop.Store(true)
<-done
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched\n", string(got), "a log line was written through the swapped-in symlink")
assert.Positive(t, ok, "positive control: appends to the regular log succeed")
}
+55 -3
View File
@@ -9,6 +9,8 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"syscall"
"time" "time"
) )
@@ -42,16 +44,63 @@ type Attempt struct {
RunnerOutput string `json:"runner_output,omitempty"` RunnerOutput string `json:"runner_output,omitempty"`
} }
// idPattern is a plain file name: session_log takes the id from the caller,
// and a path segment in it created or appended files anywhere the pod could
// write (hyperguild#169).
var idPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$`)
// ErrInvalidID is returned when a session id is not a plain file name.
var ErrInvalidID = errors.New("session id is not a plain name")
func checkID(sessionID string) error {
if !idPattern.MatchString(sessionID) {
return fmt.Errorf("%w: %q", ErrInvalidID, sessionID)
}
return nil
}
// Append writes entry as a single JSON line to sessionsDir/{sessionID}.jsonl. // Append writes entry as a single JSON line to sessionsDir/{sessionID}.jsonl.
// The open does not follow a final symlink.
func Append(sessionsDir, sessionID string, entry Entry) error { func Append(sessionsDir, sessionID string, entry Entry) error {
if err := os.MkdirAll(sessionsDir, 0o755); err != nil { if err := checkID(sessionID); err != nil {
return err
}
// Opened through an os.Root on the sessions dir's parent (the brain dir),
// so a sessions dir swapped for a symlink after the check below can
// redirect the log only inside that parent, never outside it (#173; the
// in-brain swap is the #167 residual).
parentDir, name := filepath.Dir(sessionsDir), filepath.Base(sessionsDir)
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return fmt.Errorf("create sessions parent: %w", err)
}
root, err := os.OpenRoot(parentDir)
if err != nil {
return fmt.Errorf("open sessions parent: %w", err)
}
defer func() { _ = root.Close() }()
if err := root.Mkdir(name, 0o755); err != nil && !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("create sessions dir: %w", err) return fmt.Errorf("create sessions dir: %w", err)
} }
path := filepath.Join(sessionsDir, sessionID+".jsonl") // The sessions dir itself must be a real directory, not a symlink that
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) // redirects the log elsewhere (hyperguild#173).
if fi, err := root.Lstat(name); err != nil {
return fmt.Errorf("stat sessions dir: %w", err)
} else if !fi.IsDir() {
return fmt.Errorf("sessions dir %s is not a real directory", sessionsDir)
}
logRel := name + "/" + sessionID + ".jsonl"
f, err := root.OpenFile(logRel, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
if err != nil { if err != nil {
return fmt.Errorf("open session log: %w", err) return fmt.Errorf("open session log: %w", err)
} }
// os.Root follows an in-root final symlink even with O_NOFOLLOW, so
// confirm, before writing, that the path is the regular file we opened.
hfi, herr := f.Stat()
lfi, lerr := root.Lstat(logRel)
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
_ = f.Close()
return fmt.Errorf("session log %s is not a regular file", logRel)
}
line, err := json.Marshal(entry) line, err := json.Marshal(entry)
if err != nil { if err != nil {
@@ -70,6 +119,9 @@ func Append(sessionsDir, sessionID string, entry Entry) error {
// Read returns all entries for sessionID. Returns empty slice if no log exists. // Read returns all entries for sessionID. Returns empty slice if no log exists.
func Read(sessionsDir, sessionID string) ([]Entry, error) { func Read(sessionsDir, sessionID string) ([]Entry, error) {
if err := checkID(sessionID); err != nil {
return nil, err
}
path := filepath.Join(sessionsDir, sessionID+".jsonl") path := filepath.Join(sessionsDir, sessionID+".jsonl")
f, err := os.Open(path) f, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
@@ -1,75 +0,0 @@
// Package startupretry retries a startup dependency that may not be serving
// yet, instead of exiting on its first failure.
//
// It exists for the cold-boot ordering race (#83): on a cluster restart the
// ingestion pod can reach OIDC discovery before dex is answering, gets a 502
// from ingress, and dies — after having already connected postgres, backfilled
// the graph and ingested session batches, all of which the restart redoes.
// Nothing is misconfigured in that scenario; the pod simply started first.
//
// The budget is bounded on purpose. Retrying forever would turn a genuine
// misconfiguration (wrong issuer URL, bad audience) into a process that looks
// healthy and never serves, which is harder to diagnose than a crash loop.
package startupretry
import (
"context"
"fmt"
"time"
)
// Policy bounds the retry: at most Attempts calls, with the delay doubling
// from Base and capped at Max.
type Policy struct {
Attempts int // total calls, including the first; < 1 means 1
Base time.Duration // delay before the second attempt
Max time.Duration // ceiling for the doubling delay
}
// Do calls fn until it succeeds, the attempt budget is spent, or ctx ends.
//
// The returned error wraps the last failure, so callers keep the diagnosis
// they would have had without the retry.
func Do[T any](ctx context.Context, p Policy, fn func(context.Context) (T, error)) (T, error) {
var zero T
attempts := p.Attempts
if attempts < 1 {
attempts = 1
}
delay := p.Base
var lastErr error
for i := range attempts {
if err := ctx.Err(); err != nil {
return zero, fmt.Errorf("startupretry: %w", err)
}
v, err := fn(ctx)
if err == nil {
return v, nil
}
lastErr = err
if i == attempts-1 {
break
}
if delay <= 0 {
delay = time.Second
}
if p.Max > 0 && delay > p.Max {
delay = p.Max
}
select {
case <-ctx.Done():
return zero, fmt.Errorf("startupretry: %w", ctx.Err())
case <-time.After(delay):
}
delay *= 2
}
return zero, fmt.Errorf("startupretry: gave up after %d attempts: %w", attempts, lastErr)
}
@@ -1,142 +0,0 @@
// ingestion/internal/startupretry/startupretry_test.go
package startupretry
import (
"context"
"errors"
"testing"
"time"
)
func fastPolicy(attempts int) Policy {
return Policy{Attempts: attempts, Base: time.Millisecond, Max: 2 * time.Millisecond}
}
func TestDo_ReturnsImmediatelyOnFirstSuccess(t *testing.T) {
calls := 0
got, err := Do(context.Background(), fastPolicy(5), func(context.Context) (string, error) {
calls++
return "ready", nil
})
if err != nil {
t.Fatalf("Do: %v", err)
}
if got != "ready" {
t.Errorf("got %q, want %q", got, "ready")
}
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
}
func TestDo_SurvivesTransientFailuresThenSucceeds(t *testing.T) {
// The observed cold-boot race: dex is not serving yet, ingress answers
// 502 for the first few attempts, then discovery works.
calls := 0
got, err := Do(context.Background(), fastPolicy(5), func(context.Context) (string, error) {
calls++
if calls < 4 {
return "", errors.New("oidc discovery: status 502")
}
return "ready", nil
})
if err != nil {
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
}
if got != "ready" {
t.Errorf("got %q, want %q", got, "ready")
}
if calls != 4 {
t.Errorf("calls = %d, want 4", calls)
}
}
func TestDo_GivesUpAfterTheAttemptBudgetAndReportsTheLastError(t *testing.T) {
// A genuinely broken issuer must still fail — retrying forever would turn
// a config error into a silent hang, which is worse than crash-looping.
last := errors.New("oidc discovery: status 404")
calls := 0
_, err := Do(context.Background(), fastPolicy(3), func(context.Context) (string, error) {
calls++
return "", last
})
if !errors.Is(err, last) {
t.Fatalf("err = %v, want it to wrap the last failure", err)
}
if calls != 3 {
t.Errorf("calls = %d, want 3 (the attempt budget)", calls)
}
}
func TestDo_StopsPromptlyWhenTheContextIsCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
done := make(chan struct{})
var err error
go func() {
defer close(done)
_, err = Do(ctx, Policy{Attempts: 100, Base: 50 * time.Millisecond, Max: time.Second},
func(context.Context) (string, error) {
calls++
if calls == 1 {
cancel()
}
return "", errors.New("still down")
})
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Do did not return after context cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestDo_BacksOffAndIsCappedByMax(t *testing.T) {
var delays []time.Duration
p := Policy{Attempts: 5, Base: 10 * time.Millisecond, Max: 25 * time.Millisecond}
last := time.Now()
_, _ = Do(context.Background(), p, func(context.Context) (string, error) {
now := time.Now()
delays = append(delays, now.Sub(last))
last = now
return "", errors.New("down")
})
// delays[0] is the first call (no wait); the rest are the sleeps.
if len(delays) != 5 {
t.Fatalf("attempts = %d, want 5", len(delays))
}
if delays[1] >= delays[2] {
t.Errorf("backoff is not growing: %v then %v", delays[1], delays[2])
}
for i, d := range delays[1:] {
if d > 2*p.Max {
t.Errorf("delay %d = %v, exceeds the Max cap %v with slack", i+1, d, p.Max)
}
}
}
func TestPolicy_ZeroValueStillMakesOneAttempt(t *testing.T) {
// A caller that forgets to configure the policy must not silently get a
// no-op that returns a nil validator and a nil error.
calls := 0
_, err := Do(context.Background(), Policy{}, func(context.Context) (string, error) {
calls++
return "", errors.New("down")
})
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
if err == nil {
t.Error("err = nil, want the failure surfaced")
}
}
@@ -0,0 +1,104 @@
package symlinkguard
import (
"context"
"net"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#179: the tripwire looked only for symlinks, so a FIFO (or
// socket) in the brain passed it. A FIFO at classification.yaml then made
// startup exit; at a note path it could hang readers that skip the handle
// check.
func TestScanReportsFIFOsAndSocketsAsSpecialFiles(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "classification.yaml"), 0o644))
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "wiki", "w", "f.md"), 0o644))
l, err := net.Listen("unix", filepath.Join(brain, "s.sock"))
require.NoError(t, err)
defer func() { _ = l.Close() }()
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "w", "n.md"), []byte("n"), 0o644))
r, err := Scan(brain)
require.NoError(t, err)
assert.Equal(t, []string{"classification.yaml", "s.sock", "wiki/w/f.md"}, r.Special)
assert.False(t, r.Clean())
assert.Contains(t, r.String(), "special file")
}
// Watch removes a FIFO as it removes symlinks, and leaves a regular file
// (which Scan never reports) alone.
func TestWatchRemovesAFIFOAndLeavesAnUnreportedRegularFile(t *testing.T) {
brain := t.TempDir()
fifo := filepath.Join(brain, "f.md")
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
regular := filepath.Join(brain, "n.md")
require.NoError(t, os.WriteFile(regular, []byte("n"), 0o644))
n := &recNotifier{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, n)
require.Eventually(t, func() bool {
_, err := os.Lstat(fifo)
return os.IsNotExist(err)
}, 2*time.Second, 10*time.Millisecond, "the FIFO is removed")
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 10*time.Millisecond)
b, err := os.ReadFile(regular)
require.NoError(t, err)
assert.Equal(t, "n", string(b))
n.mu.Lock()
defer n.mu.Unlock()
assert.Contains(t, n.msgs[0], "f.md")
}
// removeSpecial re-checks the type: a path that is a regular file or a
// directory at the re-check is not removed. (A swap between the re-check and
// the unlink can still remove one; Linux has no conditional unlink. The same
// window exists in removeSymlinks.)
func TestRemoveSpecialRefusesRegularFilesAndDirectories(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(brain, "n.md"), []byte("n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "d"), 0o755))
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "f.md"), 0o644))
removed, failed := removeSpecial(brain, []string{"n.md", "d", "f.md"})
assert.Equal(t, []string{"f.md"}, removed)
assert.Equal(t, []string{"n.md", "d"}, failed)
_, err := os.Stat(filepath.Join(brain, "n.md"))
assert.NoError(t, err)
}
// A special file Watch cannot remove (its directory is read-only) stays
// reported: the persistent-finding alert names it.
func TestWatchReportsASpecialFileItCannotRemove(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
brain := t.TempDir()
ro := filepath.Join(brain, "ro")
require.NoError(t, os.MkdirAll(ro, 0o755))
require.NoError(t, syscall.Mkfifo(filepath.Join(ro, "f.md"), 0o644))
require.NoError(t, os.Chmod(ro, 0o555))
t.Cleanup(func() { _ = os.Chmod(ro, 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)
_, err := os.Lstat(filepath.Join(ro, "f.md"))
require.NoError(t, err, "the FIFO could not be removed")
n.mu.Lock()
defer n.mu.Unlock()
assert.Contains(t, n.msgs[0], "ro/f.md")
assert.Contains(t, n.msgs[0], "special file")
for _, m := range n.msgs {
assert.NotContains(t, m, "removed", "a file it could not remove was reported as removed")
}
}
+152 -6
View File
@@ -18,9 +18,14 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic"
"time" "time"
) )
// shutdownFlushTimeout bounds the final flush when Watch stops.
var shutdownFlushTimeout = 5 * time.Second
// DefaultInterval is used when Watch is given a non-positive interval. // DefaultInterval is used when Watch is given a non-positive interval.
const DefaultInterval = 30 * time.Second const DefaultInterval = 30 * time.Second
@@ -41,10 +46,16 @@ type Notifier interface {
type Result struct { type Result struct {
Symlinks []string Symlinks []string
Unreadable []string Unreadable []string
// Special holds FIFOs, sockets and devices (hyperguild#179): a reader
// that opens one by path can hang, and a FIFO at classification.yaml
// stops startup.
Special []string
} }
// Clean reports whether the scan found nothing to act on. // Clean reports whether the scan found nothing to act on.
func (r Result) Clean() bool { return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 } func (r Result) Clean() bool {
return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 && len(r.Special) == 0
}
// String describes the findings for a log line or alert. Paths are quoted, so // String describes the findings for a log line or alert. Paths are quoted, so
// a name containing a newline cannot blur two findings together. // a name containing a newline cannot blur two findings together.
@@ -54,6 +65,9 @@ func (r Result) String() string {
if len(r.Symlinks) > 0 { if len(r.Symlinks) > 0 {
parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks))) parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks)))
} }
if len(r.Special) > 0 {
parts = append(parts, fmt.Sprintf("%d special file(s) (FIFO, socket or device; delete them): %s", len(r.Special), q(r.Special)))
}
if len(r.Unreadable) > 0 { if len(r.Unreadable) > 0 {
parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable))) parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable)))
} }
@@ -108,12 +122,16 @@ func Scan(brainDir string) (Result, error) {
if d.IsDir() && p == rootGit { if d.IsDir() && p == rootGit {
return filepath.SkipDir return filepath.SkipDir
} }
if d.Type()&os.ModeSymlink != 0 { switch {
case d.Type()&os.ModeSymlink != 0:
r.Symlinks = append(r.Symlinks, rel) r.Symlinks = append(r.Symlinks, rel)
case !d.IsDir() && !d.Type().IsRegular():
r.Special = append(r.Special, rel)
} }
return nil return nil
}) })
sort.Strings(r.Symlinks) sort.Strings(r.Symlinks)
sort.Strings(r.Special)
sort.Strings(r.Unreadable) sort.Strings(r.Unreadable)
if walkErr != nil { if walkErr != nil {
// Only the root's own error reaches here (deeper errors are recorded // Only the root's own error reaches here (deeper errors are recorded
@@ -133,13 +151,32 @@ func Scan(brainDir string) (Result, error) {
// queue until Notify succeeds (see alertQueue): an outage delays them, and // queue until Notify succeeds (see alertQueue): an outage delays them, and
// any that do not fit the bounded queue, or are undeliverable, are counted in // any that do not fit the bounded queue, or are undeliverable, are counted in
// a summary that is never dropped, so every incident is delivered or counted. // a summary that is never dropped, so every incident is delivered or counted.
//
// Delivery runs in its own goroutine (hyperguild#164): a hung notifier, with
// a full queue, blocked one flush for several notify timeouts, and while the
// scan waited on it a planted link stayed readable for several intervals.
// The scan hands alerts over without blocking; any that do not fit are
// counted in the summary. When ctx ends, Watch returns after a final flush
// of what was handed over (bounded by shutdownFlushTimeout); alerts that
// flush cannot deliver are counted in one error log line instead. That
// shutdown path only runs for a caller that cancels ctx: cmd/server passes
// context.Background(), so in production the flush on handoff is what keeps
// alerts from waiting on a restart.
func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) { func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) {
if interval <= 0 { if interval <= 0 {
interval = DefaultInterval interval = DefaultInterval
} }
t := time.NewTicker(interval) t := time.NewTicker(interval)
defer t.Stop() defer t.Stop()
q := &alertQueue{} alerts := make(chan string, maxPending)
var overflow atomic.Int64
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); deliver(ctx, interval, n, alerts, &overflow) }()
// The scan is the only sender: closing alerts tells deliver to drain and
// flush once more, and Watch returns only after it has.
defer func() { close(alerts); wg.Wait() }()
q := alertSender{alerts: alerts, overflow: &overflow}
reported := "" reported := ""
for { for {
select { select {
@@ -158,17 +195,92 @@ func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notif
q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s", q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s",
len(removed), Result{}.quoted(removed))) len(removed), Result{}.quoted(removed)))
} }
key = Result{Symlinks: failed, Unreadable: r.Unreadable}.String() sRemoved, sFailed := removeSpecial(brainDir, r.Special)
if len(sRemoved) > 0 {
slog.Error("brain symlink tripwire removed special files", "count", len(sRemoved))
q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#179): removed %d special file(s) (FIFO, socket or device) from the brain dir: %s",
len(sRemoved), Result{}.quoted(sRemoved)))
}
key = Result{Symlinks: failed, Unreadable: r.Unreadable, Special: sFailed}.String()
} }
if key != reported { if key != reported {
reported = key reported = key
if key != "" { if key != "" {
slog.Error("brain symlink tripwire", "finding", key) slog.Error("brain symlink tripwire", "finding", key)
q.push("brain symlink tripwire (hyperguild#157): " + key + q.push("brain symlink tripwire (hyperguild#157, #179): " + key +
". Readers may follow symlinks out of the brain; fix them.") ". Symlinks can lead readers out of the brain and special files can hang them; remove them.")
} }
} }
}
}
// alertSender hands alerts to deliver without ever blocking the scan; an
// alert that does not fit is counted in overflow, which deliver adds to the
// summary.
type alertSender struct {
alerts chan<- string
overflow *atomic.Int64
}
func (s alertSender) push(msg string) {
select {
case s.alerts <- msg:
default:
s.overflow.Add(1)
}
}
// deliver owns the alertQueue. It flushes as soon as an alert is handed
// over, and again every interval while any are pending (outage retries).
// When alerts is closed (Watch is stopping) it drains it and flushes once
// more, bounded by shutdownFlushTimeout, so an alert handed over just before
// shutdown is not lost (PR #180 review). Whatever that flush cannot deliver
// is logged as one error with its counts, never dropped silently.
func deliver(ctx context.Context, interval time.Duration, n Notifier, alerts <-chan string, overflow *atomic.Int64) {
t := time.NewTicker(interval)
defer t.Stop()
q := &alertQueue{}
// drain moves everything handed over into the queue; it reports false
// once alerts is closed.
drain := func() bool {
defer func() { q.suppressed += int(overflow.Swap(0)) }()
for {
select {
case msg, ok := <-alerts:
if !ok {
return false
}
q.push(msg)
default:
return true
}
}
}
for {
select {
case msg, ok := <-alerts:
if ok {
q.push(msg)
ok = drain()
}
if !ok {
q.suppressed += int(overflow.Swap(0))
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownFlushTimeout)
q.flush(fctx, n)
cancel()
if len(q.items) > 0 || q.suppressed > 0 {
slog.Error("symlink tripwire alerts not delivered at shutdown",
"undelivered", len(q.items), "suppressed", q.suppressed)
}
return
}
q.flush(ctx, n) q.flush(ctx, n)
case <-t.C:
if !drain() {
continue // the closed channel is handled by the case above
}
q.flush(ctx, n)
}
} }
} }
@@ -229,6 +341,40 @@ func permanent(err error) bool {
return errors.As(err, &p) && p.Permanent() return errors.As(err, &p) && p.Permanent()
} }
// removeSpecial unlinks each brain-relative FIFO, socket or device through
// an os.Root on the brain dir, after re-checking that it is still one: a
// path that is a regular file, a directory or a symlink at the re-check is
// left alone and reported as failed. The re-check narrows, but cannot close,
// the window before the unlink (Linux has no conditional unlink); the same
// window exists in removeSymlinks, and only a local writer can use it.
func removeSpecial(brainDir string, rels []string) (removed, failed []string) {
if len(rels) == 0 {
return nil, nil
}
resolved, err := filepath.EvalSymlinks(brainDir)
if err != nil {
return nil, rels
}
root, err := os.OpenRoot(resolved)
if err != nil {
return nil, rels
}
defer func() { _ = root.Close() }()
for _, rel := range rels {
fi, err := root.Lstat(rel)
if err != nil || fi.IsDir() || fi.Mode().IsRegular() || fi.Mode()&os.ModeSymlink != 0 {
failed = append(failed, rel)
continue
}
if err := root.Remove(rel); err != nil {
failed = append(failed, rel)
continue
}
removed = append(removed, rel)
}
return removed, failed
}
// removeSymlinks unlinks each brain-relative symlink through an os.Root on // removeSymlinks unlinks each brain-relative symlink through an os.Root on
// the brain dir, after re-checking it is still a symlink. Removing a symlink // the brain dir, after re-checking it is still a symlink. Removing a symlink
// never touches its target. It returns what was removed and what was not. // never touches its target. It returns what was removed and what was not.
@@ -1,10 +1,12 @@
package symlinkguard package symlinkguard
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"log/slog"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -345,13 +347,19 @@ func TestWatchDropsAMessageThatNeverSendsSoLaterAlertsFlow(t *testing.T) {
}, 3*time.Second, 10*time.Millisecond, "a later alert is delivered despite a poison message") }, 3*time.Second, 10*time.Millisecond, "a later alert is delivered despite a poison message")
} }
// toggleNotifier fails while down is set. // toggleNotifier fails while down is set. Like the real audit.NtfyNotifier
// (http.NewRequestWithContext), it also fails, without recording, when its
// ctx is already done (PR #180 round 2: notifiers that ignored ctx let a
// final flush on the cancelled ctx pass).
type toggleNotifier struct { type toggleNotifier struct {
recNotifier recNotifier
down atomic.Bool down atomic.Bool
} }
func (r *toggleNotifier) Notify(ctx context.Context, m string) error { func (r *toggleNotifier) Notify(ctx context.Context, m string) error {
if err := ctx.Err(); err != nil {
return err
}
if r.down.Load() { if r.down.Load() {
return errors.New("ntfy down") return errors.New("ntfy down")
} }
@@ -373,18 +381,19 @@ func TestWatchQueueIsBoundedDuringAnOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond) require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
} }
n.down.Store(false) n.down.Store(false)
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 5*time.Millisecond) // Synchronise on the summary being delivered (hyperguild#193: a fixed
time.Sleep(100 * time.Millisecond) // sleep flaked under load); once it is, the queue has been flushed.
assert.LessOrEqual(t, n.count(), maxPending+1, "queued alerts plus at most one suppression summary") require.Eventually(t, func() bool {
n.mu.Lock() n.mu.Lock()
defer n.mu.Unlock() defer n.mu.Unlock()
found := false
for _, m := range n.msgs { for _, m := range n.msgs {
if strings.Contains(m, "suppressed") { if strings.Contains(m, "suppressed") {
found = true return true
} }
} }
assert.True(t, found, "suppressed alerts are counted in a summary") return false
}, 10*time.Second, 5*time.Millisecond, "suppressed alerts are counted in a summary")
assert.LessOrEqual(t, n.count(), maxPending+1, "queued alerts plus at most one suppression summary")
} }
// Within the bound, every alert queued during an outage is delivered after // Within the bound, every alert queued during an outage is delivered after
@@ -403,11 +412,21 @@ func TestWatchDeliversEveryQueuedAlertAfterAShortOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond) require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
} }
n.down.Store(false) n.down.Store(false)
require.Eventually(t, func() bool { return n.count() == 3 }, 2*time.Second, 5*time.Millisecond) require.Eventually(t, func() bool { return n.count() >= 3 }, 10*time.Second, 5*time.Millisecond)
n.mu.Lock() n.mu.Lock()
defer n.mu.Unlock() defer n.mu.Unlock()
for i, name := range []string{"qa", "qb", "qc"} { // Each removal delivered exactly once, in any order: the outage ends
assert.Contains(t, n.msgs[i], strconv.Quote(name)) // asynchronously, possibly mid-flush, so a later item can send before an
// earlier one (hyperguild#193). Order is not what this test is about.
assert.Len(t, n.msgs, 3)
for _, name := range []string{"qa", "qb", "qc"} {
hits := 0
for _, m := range n.msgs {
if strings.Contains(m, strconv.Quote(name)) {
hits++
}
}
assert.Equal(t, 1, hits, "%s delivered exactly once", name)
} }
} }
@@ -449,29 +468,30 @@ func TestWatchAccountsForEveryRemovalDuringAnOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 1*time.Millisecond) require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 1*time.Millisecond)
} }
n.down.Store(false) n.down.Store(false)
require.Eventually(t, func() bool { // Synchronise on the record being complete, not on a fixed sleep after
// the first summary (hyperguild#193): sum every "removed N" alert and
// every suppression count until they account for all plants.
account := func() (delivered, counted int) {
n.mu.Lock() n.mu.Lock()
defer n.mu.Unlock() defer n.mu.Unlock()
for _, m := range n.msgs { for _, m := range n.msgs {
if strings.Contains(m, "suppressed") { var k int
return true if i := strings.Index(m, "removed "); i >= 0 && strings.Contains(m, "symlink(s)") {
if _, err := fmt.Sscanf(m[i+len("removed "):], "%d", &k); err == nil {
delivered += k
} }
} }
return false
}, 2*time.Second, 5*time.Millisecond)
time.Sleep(50 * time.Millisecond)
n.mu.Lock()
defer n.mu.Unlock()
delivered, counted := 0, 0
for _, m := range n.msgs {
if strings.Contains(m, "removed 1 symlink(s)") {
delivered++
}
if i := strings.Index(m, "): "); i >= 0 && strings.Contains(m, "suppressed") { if i := strings.Index(m, "): "); i >= 0 && strings.Contains(m, "suppressed") {
_, _ = fmt.Sscanf(m[i+3:], "%d", &counted) if _, err := fmt.Sscanf(m[i+3:], "%d", &k); err == nil {
counted += k
} }
} }
assert.Equal(t, plants, delivered+counted, "delivered %d + counted %d must equal %d removals", delivered, counted, plants) }
return delivered, counted
}
require.Eventually(t, func() bool { d, c := account(); return d+c >= plants }, 10*time.Second, 5*time.Millisecond)
d, c := account()
assert.Equal(t, plants, d+c, "delivered %d + counted %d must equal %d removals", d, c, plants)
} }
// A poison message is still dropped (others send while it fails), and the // A poison message is still dropped (others send while it fails), and the
@@ -496,3 +516,271 @@ func TestWatchDropsAPoisonMessageAndCountsIt(t *testing.T) {
return false return false
}, 3*time.Second, 5*time.Millisecond, "the dropped poison alert is counted in a summary") }, 3*time.Second, 5*time.Millisecond, "the dropped poison alert is counted in a summary")
} }
// hungNotifier answers every Notify only after latency (or when ctx ends),
// with an outage error: an ntfy that accepts connections but never responds
// until its timeout.
type hungNotifier struct{ latency time.Duration }
func (h hungNotifier) Notify(ctx context.Context, _ string) error {
select {
case <-time.After(h.latency):
case <-ctx.Done():
}
return errors.New("ntfy timeout")
}
func waitGone(t *testing.T, link string) {
t.Helper()
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 10*time.Second, time.Millisecond)
}
// hyperguild#164: alerts were flushed in the scan goroutine, so with a hung
// ntfy and a full queue one flush (6 x notify timeout) delayed the next scan.
// The notifier here is slower than the interval, as in production (10 s
// timeout vs 30 s interval with 6 messages), so the alert handoff is full:
// a scan that waited on it would keep a link for seconds.
func TestWatchLinkLifetimeStaysNearOneIntervalWithANotifierSlowerThanTheInterval(t *testing.T) {
const interval = 100 * time.Millisecond
brain := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, interval, hungNotifier{latency: 400 * time.Millisecond})
for i := 0; i < 2*maxPending+3; i++ { // overfill queue and handoff
link := filepath.Join(brain, "fill"+strconv.Itoa(i))
require.NoError(t, os.Symlink(t.TempDir(), link))
waitGone(t, link)
}
var worst time.Duration
for i := 0; i < 6; i++ {
link := filepath.Join(brain, "probe"+strconv.Itoa(i))
require.NoError(t, os.Symlink(t.TempDir(), link))
planted := time.Now()
waitGone(t, link)
worst = max(worst, time.Since(planted))
}
assert.LessOrEqual(t, worst, interval+interval/2, "worst link lifetime %v with a hung notifier", worst)
}
// Review of PR #180: delivery waited for its own tick, about one interval
// after the removal. An alert is now flushed as soon as it is handed over.
func TestWatchDeliversARemovalAlertWellWithinOneInterval(t *testing.T) {
const interval = 400 * time.Millisecond
brain := t.TempDir()
n := &recNotifier{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, interval, n)
link := filepath.Join(brain, "prompt")
require.NoError(t, os.Symlink(t.TempDir(), link))
waitGone(t, link)
removed := time.Now()
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, time.Millisecond)
assert.Less(t, time.Since(removed), interval/4)
}
// Review of PR #180: on ctx end, deliver returned without draining what the
// scan had handed over, so a removal just before shutdown was neither
// delivered nor counted. Watch now drains and flushes before returning.
func TestWatchDeliversAnAlertHandedOverJustBeforeShutdown(t *testing.T) {
for i := 0; i < 20; i++ {
brain := t.TempDir()
n := &recNotifier{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { Watch(ctx, brain, 5*time.Millisecond, n); close(done) }()
link := filepath.Join(brain, "late")
require.NoError(t, os.Symlink(t.TempDir(), link))
waitGone(t, link)
cancel()
<-done
require.Equal(t, 1, n.count(), "iteration %d: the removal alert was lost at shutdown", i)
}
}
// blockingNotifier holds every Notify until its ctx ends, tracking calls in
// flight.
type blockingNotifier struct{ inFlight atomic.Int32 }
func (b *blockingNotifier) Notify(ctx context.Context, _ string) error {
b.inFlight.Add(1)
defer b.inFlight.Add(-1)
<-ctx.Done()
return ctx.Err()
}
// Watch does not return while its delivery goroutine is still notifying.
func TestWatchReturnsOnlyAfterDeliveryHasStopped(t *testing.T) {
prev := shutdownFlushTimeout
shutdownFlushTimeout = 50 * time.Millisecond
t.Cleanup(func() { shutdownFlushTimeout = prev })
brain := t.TempDir()
n := &blockingNotifier{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { Watch(ctx, brain, 5*time.Millisecond, n); close(done) }()
link := filepath.Join(brain, "x")
require.NoError(t, os.Symlink(t.TempDir(), link))
waitGone(t, link)
require.Eventually(t, func() bool { return n.inFlight.Load() == 1 }, 2*time.Second, time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Watch did not return after cancel")
}
assert.Zero(t, n.inFlight.Load(), "Notify still running after Watch returned")
}
// slowToggleNotifier fails slowly (a hung ntfy) while down, then records.
type slowToggleNotifier struct {
recNotifier
down atomic.Bool
latency time.Duration
}
func (r *slowToggleNotifier) Notify(ctx context.Context, m string) error {
if r.down.Load() {
select {
case <-time.After(r.latency):
case <-ctx.Done():
}
return errors.New("ntfy timeout")
}
return r.recNotifier.Notify(ctx, m)
}
// Review of PR #180: with a slow notifier the scan's handoff overflows, and
// those alerts exist only in the overflow counter. Every removal is still
// delivered or counted in the summary.
func TestWatchCountsHandoffOverflowWithASlowNotifier(t *testing.T) {
brain := t.TempDir()
n := &slowToggleNotifier{latency: 30 * time.Millisecond}
n.down.Store(true)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 2*time.Millisecond, n)
const plants = 40
for i := 0; i < plants; i++ {
link := filepath.Join(brain, "ov"+strconv.Itoa(i))
require.NoError(t, os.Symlink(t.TempDir(), link))
waitGone(t, link)
}
n.down.Store(false)
delivered, counted := 0, 0
require.Eventually(t, func() bool {
n.mu.Lock()
defer n.mu.Unlock()
delivered, counted = 0, 0
for _, m := range n.msgs {
if strings.Contains(m, "removed 1 symlink(s)") {
delivered++
}
if i := strings.Index(m, "): "); i >= 0 && strings.Contains(m, "suppressed") {
var c int
_, _ = fmt.Sscanf(m[i+3:], "%d", &c)
counted += c
}
}
return delivered+counted == plants
}, 5*time.Second, 10*time.Millisecond, "delivered %d + counted %d of %d", delivered, counted, plants)
}
// An alert still queued when Watch stops (its send failed, the notifier has
// since recovered, the retry tick has not come) is flushed once more before
// Watch returns, on a ctx that is not the cancelled one: toggleNotifier fails
// on a done ctx, as the real ntfy notifier does.
func TestWatchFinalFlushSendsAQueuedAlertOnACtxThatOutlivesTheCancel(t *testing.T) {
brain := t.TempDir()
n := &toggleNotifier{}
n.down.Store(true)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { Watch(ctx, brain, 300*time.Millisecond, n); close(done) }()
link := filepath.Join(brain, "queued")
require.NoError(t, os.Symlink(t.TempDir(), link))
waitGone(t, link)
time.Sleep(20 * time.Millisecond) // let the immediate send fail
n.down.Store(false)
cancel()
<-done
assert.Equal(t, 1, n.count(), "the queued alert was not flushed at shutdown")
}
// lockedBuffer is a goroutine-safe log sink: Watch goroutines of earlier
// tests may still log while one test captures.
type lockedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *lockedBuffer) String() string { b.mu.Lock(); defer b.mu.Unlock(); return b.buf.String() }
func captureLog(t *testing.T) *lockedBuffer {
t.Helper()
b := &lockedBuffer{}
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(b, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
return b
}
// PR #180 round 2: when the final flush fails (ntfy hung at shutdown) the
// undelivered alerts vanished with only "will retry" warnings. Watch now logs
// one error with both counts.
func TestWatchLogsUndeliveredAndSuppressedCountsWhenTheFinalFlushFails(t *testing.T) {
prev := shutdownFlushTimeout
shutdownFlushTimeout = 50 * time.Millisecond
t.Cleanup(func() { shutdownFlushTimeout = prev })
logs := captureLog(t)
brain := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { Watch(ctx, brain, 5*time.Millisecond, hungNotifier{latency: time.Hour}); close(done) }()
link := filepath.Join(brain, "lost")
require.NoError(t, os.Symlink(t.TempDir(), link))
waitGone(t, link)
cancel()
<-done
out := logs.String()
assert.Contains(t, out, "level=ERROR")
assert.Contains(t, out, `msg="symlink tripwire alerts not delivered at shutdown"`)
assert.Contains(t, out, "undelivered=1")
assert.Contains(t, out, "suppressed=0")
}
// deliver, given a handoff that already holds alerts and is closed, moves them
// into the queue and runs the final flush, even though the ctx is cancelled:
// the drain-on-close path, without depending on when the close lands.
func TestDeliverFlushesAlertsDrainedFromAClosedHandoffOnACancelledCtx(t *testing.T) {
alerts := make(chan string, 3)
alerts <- "a1"
alerts <- "a2"
close(alerts)
ctx, cancel := context.WithCancel(context.Background())
cancel()
n := &toggleNotifier{}
var overflow atomic.Int64
deliver(ctx, time.Hour, n, alerts, &overflow)
assert.Equal(t, []string{"a1", "a2"}, n.msgs)
}
// PR #180 round 2: when the first receive after close finds the handoff empty,
// overflow counted by the scan was never moved into the summary.
func TestDeliverCountsOverflowWhenTheHandoffIsClosedAndEmpty(t *testing.T) {
alerts := make(chan string)
close(alerts)
n := &recNotifier{}
var overflow atomic.Int64
overflow.Store(3)
deliver(context.Background(), time.Hour, n, alerts, &overflow)
require.Len(t, n.msgs, 1)
assert.Contains(t, n.msgs[0], "3 further alert(s)")
}
@@ -42,6 +42,11 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir() brain := t.TempDir()
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note") writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md"))) require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
// A link to a file elsewhere in the brain: os.Root allows it, only the
// regular-file checks refuse it (hyperguild#171). raw/ is not a scan root,
// so its sentinel can only arrive through the link.
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "w", "facts", "inlink.md")))
emb := &recordingEmbedder{} emb := &recordingEmbedder{}
store := &stubStore{} store := &stubStore{}
@@ -51,6 +56,7 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
assert.NotContains(t, emb.all(), brainfstest.Sentinel) assert.NotContains(t, emb.all(), brainfstest.Sentinel)
for p := range store.upserts { for p := range store.upserts {
assert.NotContains(t, p, "leak.md") assert.NotContains(t, p, "leak.md")
assert.NotContains(t, p, "inlink.md")
} }
p := filepath.Join(brain, "knowledge", "r.md") p := filepath.Join(brain, "knowledge", "r.md")
@@ -61,6 +67,7 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
return e.all() return e.all()
} }
brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, sync) brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, sync)
brainfstest.AssertNoLeakFrom(t, p, "inside", inBrain, 1500*time.Millisecond, sync)
brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = sync() }) brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = sync() })
} }
@@ -0,0 +1,116 @@
package watcher
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
)
// These tests are the sprint's goal (hyperguild#209) stated as behaviour, with
// the real pipeline and real file layout and only the model stubbed: a person
// drops a clip into raw/ and does nothing else.
func acceptanceCfg(t *testing.T) (Config, string, *atomic.Int32) {
t.Helper()
brainDir := t.TempDir()
var calls atomic.Int32
// Each call extracts a source with its own title, as real clips have: two
// sources with one title are deliberately merged into one page by the
// pipeline's title resolution.
pagesFor := func(n int32) []pipeline.RawPage {
return []pipeline.RawPage{
{Title: fmt.Sprintf("Dark Factory Talk %d", n), Type: "source", Subtype: "video", Domain: "ai",
Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n\n## Concepts Introduced or Reinforced\n\n[[Dark Factory]]\n"},
{Title: "Jev", Type: "entity", Subtype: "tool", Content: "## Definition\n\nA decision model.\n\n## Sources\n"},
{Title: "Dark Factory", Type: "concept", Content: "## Definition\n\nA fully automated build line.\n\n## Sources\n"},
}
}
return Config{
BrainDir: brainDir,
Resolver: wing.Router{BrainDir: brainDir, Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)},
Pipeline: pipeline.Config{
Schema: "# Schema",
Complete: func(context.Context, string, string) (string, error) {
return mustJSONPages(pagesFor(calls.Add(1))), nil
},
},
}, brainDir, &calls
}
// topLevel lists the regular files directly in raw/: the inbox.
func topLevel(t *testing.T, brainDir string) []string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(brainDir, "raw"))
require.NoError(t, err)
var out []string
for _, e := range entries {
if e.Type().IsRegular() {
out = append(out, e.Name())
}
}
return out
}
func TestAcceptance_ADroppedClipLandsInWebClipAndTheInboxIsEmpty(t *testing.T) {
cfg, brainDir, calls := acceptanceCfg(t)
put(t, brainDir, "dark-factory-talk.md", "Transcript of a talk about dark factories.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.True(t, has(brainDir, "wiki/web-clip/sources/dark-factory-talk.md"), "the clip is a page in web-clip")
assert.True(t, has(brainDir, "wiki/entities/jev.md"), "its entity is in the shared layer")
assert.True(t, has(brainDir, "wiki/concepts/dark-factory.md"), "its concept is in the shared layer")
assert.Empty(t, topLevel(t, brainDir), "raw/ holds nothing unprocessed")
assert.False(t, has(brainDir, "raw/needs-review"), "and nothing waits for a human")
assert.Equal(t, int32(1), calls.Load(), "one extraction, no classification step")
}
func TestAcceptance_TheSameEntityFromTwoClipsIsOnePage(t *testing.T) {
cfg, brainDir, _ := acceptanceCfg(t)
put(t, brainDir, "talk-one.md", "First talk.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
put(t, brainDir, "talk-two.md", "Second talk, different words.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
files, err := filepath.Glob(filepath.Join(brainDir, "wiki", "entities", "*.md"))
require.NoError(t, err)
assert.Len(t, files, 1)
b, _ := os.ReadFile(filepath.Join(brainDir, "wiki/entities/jev.md"))
assert.True(t, strings.Contains(string(b), "talk-one") && strings.Contains(string(b), "talk-two"), "both sources are cited")
}
func TestAcceptance_AClipThatLooksLikeClientMaterialIsParkedNotFiled(t *testing.T) {
cfg, brainDir, calls := acceptanceCfg(t)
put(t, brainDir, "kickoff.md", "Notes from the ACME-CLIENT kickoff call.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.Zero(t, calls.Load(), "not even extracted")
assert.False(t, has(brainDir, "wiki/web-clip"), "never filed into a shared wing")
assert.True(t, has(brainDir, "raw/needs-review/kickoff.md"), "waits for the owner")
assert.Empty(t, topLevel(t, brainDir))
}
func TestAcceptance_ASubfolderNamingAnExistingWingPlacesThereAndATypoParks(t *testing.T) {
cfg, brainDir, _ := acceptanceCfg(t)
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "homelab", "facts"), 0o755))
put(t, brainDir, "homelab/ops-note.md", "A note about the cluster.")
put(t, brainDir, "hoemlab/typo-note.md", "A misfiled note.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.True(t, has(brainDir, "wiki/homelab/sources/ops-note.md"))
assert.False(t, has(brainDir, "wiki/hoemlab"), "a typo never creates a wing")
assert.True(t, has(brainDir, "raw/needs-review/typo-note.md"))
}
+51
View File
@@ -0,0 +1,51 @@
package watcher
import (
"errors"
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
)
// Kept out of watcher.go on purpose: that file is covered by the raw-reader
// guard (internal/extract/callsites_test.go), which forbids any .Open selector
// there. This function reads nothing from raw/; it creates a file under the
// brain root, which refuses symlinked directories and never follows a final
// symlink.
// createParked writes data to raw/needs-review/<filename> through the brain
// root, never replacing a parked note: on a name clash it tries -2, -3, ...
// A needs-review that is a symlink, or any non-directory, is refused.
func createParked(brainDir, filename string, data []byte) error {
return createUnique(brainDir, "raw/needs-review", filename, data)
}
// createUnique writes data to dirRel/filename through the brain root, never
// replacing an existing file: on a name clash it tries -2, -3, ... A dirRel that
// is a symlink, or any non-directory, is refused.
func createUnique(brainDir, dirRel, filename string, data []byte) error {
bfs, err := brainfs.Open(brainDir)
if err != nil {
return err
}
defer func() { _ = bfs.Close() }()
ext := filepath.Ext(filename)
stem := strings.TrimSuffix(filename, ext)
for n := 1; n < 10000; n++ {
name := filename
if n > 1 {
name = fmt.Sprintf("%s-%d%s", stem, n, ext)
}
err := bfs.CreateRegular(dirRel+"/"+name, data)
if err == nil {
return nil
}
if !errors.Is(err, fs.ErrExist) {
return err
}
}
return fmt.Errorf("no free name for %q in %s", filename, dirRel)
}
+353 -53
View File
@@ -3,17 +3,22 @@ package watcher
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract" "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/wing"
) )
// Config holds watcher configuration. // Config holds watcher configuration.
@@ -21,8 +26,27 @@ type Config struct {
BrainDir string BrainDir string
Interval time.Duration Interval time.Duration
Pipeline pipeline.Config Pipeline pipeline.Config
// MaxNoteBytes refuses a note larger than this before anything is read into
// memory (hyperguild#198). 0 means defaultMaxNoteBytes.
MaxNoteBytes int64
// Resolver, when set, decides each note's wing (hyperguild#205, #208): a
// deliberate subfolder/frontmatter wing, else the default wing. A note it
// cannot place is parked in raw/needs-review/ for a human. Nil keeps the
// retired layout. Set it only to a non-nil value: a nil pointer inside the
// interface is not nil.
Resolver wing.Placer
} }
// defaultMaxNoteBytes bounds a single note: extraction and the archive copy both
// hold it in memory.
const defaultMaxNoteBytes = 64 << 20
// errTooLarge marks a note refused for its size; like a refused write it is
// reported once and not retried until the note changes.
var errTooLarge = extract.ErrTooLarge
// Start launches the watcher in a background goroutine. // Start launches the watcher in a background goroutine.
// It returns immediately. The watcher stops when ctx is cancelled. // It returns immediately. The watcher stops when ctx is cancelled.
func Start(ctx context.Context, cfg Config) { func Start(ctx context.Context, cfg Config) {
@@ -85,7 +109,7 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
// path that treats raw/ as "awaiting processing" — this is that path. // path that treats raw/ as "awaiting processing" — this is that path.
if d.IsDir() { if d.IsDir() {
name := d.Name() name := d.Name()
if name == "processed" || name == "failed" || name == "rejected" { if name == "processed" || name == "failed" || name == "rejected" || name == "needs-review" {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
@@ -109,14 +133,33 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
} }
// 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 { // Any entry at the marker path counts (Lstat, not Stat): a dangling
// marker symlink made os.Stat report nothing and the note was
// reprocessed every interval (hyperguild#158).
if skip, stale := markerBlocks(path); skip {
warnMarkerOnce(path)
return nil return nil
} else {
for _, m := range stale {
slog.Warn("watcher: ignoring a stale marker older than its note", "marker", m)
if rmErr := root.Remove(relOf(rawDir, m)); rmErr != nil {
slog.Error("watcher: failed to remove a stale marker", "marker", m, "error", rmErr)
} }
if _, err := os.Stat(path + ".failed"); err == nil { }
}
// A note whose archive copy or marker was refused is not retried
// until it changes: a refusal has no marker to stop the next round,
// and each round can cost an LLM call (hyperguild#158).
info, ierr := d.Info()
if ierr == nil && refusedUnchanged(path, info) {
return nil return nil
} }
if err := processFile(ctx, cfg, root, rawDir, path, date); err != nil { if err := processFile(ctx, cfg, root, rawDir, path, date); err != nil {
if ierr == nil && (errors.Is(err, brainfs.ErrNotRegular) || errors.Is(err, errTooLarge)) {
rememberRefused(path, info)
slog.Error("watcher: note refused; not retried until it changes", "file", filepath.Base(path), "error", err)
}
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
@@ -127,9 +170,12 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
return errs return errs
} }
// processFile reads a file, calls pipeline.Run, copies it to processed/ or failed/, // processFile reads a file, calls pipeline.Run, and archives it into processed/
// and writes a marker file next to the original so the watcher skips it next poll. // or failed/. The original is then removed from raw/ so that raw/ only ever
// The original file is never deleted, keeping Syncthing-connected vaults (e.g. Obsidian) intact. // holds unprocessed input (inbox zero). The removal syncs to Syncthing peers;
// the archive copy under raw/processed/ or raw/failed/ is what they keep. A
// marker is written next to the original only if the removal fails, so a
// stuck file is not re-ingested every poll.
func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, date string) error { func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, date string) error {
filename := filepath.Base(path) filename := filepath.Base(path)
source := deriveSource(filename) source := deriveSource(filename)
@@ -138,25 +184,43 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
return fmt.Errorf("relative path: %w", err) return fmt.Errorf("relative path: %w", err)
} }
content, err := extract.TextIn(root, rel) before, err := root.Lstat(rel)
if err != nil {
return fmt.Errorf("stat before ingest: %w", err)
}
limit := cfg.MaxNoteBytes
if limit <= 0 {
limit = defaultMaxNoteBytes
}
if before.Size() > limit {
return fmt.Errorf("%s is %d bytes, over the %d byte cap: %w", filename, before.Size(), limit, errTooLarge)
}
content, err := extract.TextInMax(root, rel, limit)
if err != nil { if err != nil {
return fmt.Errorf("extract text: %w", err) return fmt.Errorf("extract text: %w", err)
} }
_, runErr := pipeline.Run(ctx, cfg.Pipeline, cfg.BrainDir, content, source, false) pcfg := cfg.Pipeline
if runErr != nil { if cfg.Resolver != nil {
// Copy to failed/ and leave a .failed marker so we don't retry. d := cfg.Resolver.Resolve(ctx, wing.Note{Dir: topDir(rel), FrontmatterWing: frontmatterWing(content), Text: content})
failedDir := filepath.Join(cfg.BrainDir, "raw", "failed") if d.Source == wing.Exception {
if mkErr := os.MkdirAll(failedDir, 0o755); mkErr != nil { return parkForReview(cfg, root, rel, path, filename, content, d, date, before)
return fmt.Errorf("mkdir failed dir: %w", mkErr)
} }
dest := filepath.Join(failedDir, filename) pcfg.Wing = d.Wing
if cpErr := copyFromRoot(root, rel, dest); cpErr != nil { slog.Info("watcher: wing resolved", "file", filename, "wing", d.Wing, "how", d.Source, "reason", d.Reason)
}
_, runErr := pipeline.Run(ctx, pcfg, cfg.BrainDir, content, source, false)
if runErr != nil {
// Copy to failed/ and leave a .failed marker so we don't retry. Both
// written through the brain root (hyperguild#158).
if cpErr := copyFromRoot(root, rel, cfg.BrainDir, "raw/failed", filename, limit); 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 changedSince(root, rel, before) {
slog.Error("watcher: failed to write .failed marker", "error", mkErr) slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
return nil
} }
retire(cfg, root, rel, path, ".failed", runErr.Error(), before)
slog.Warn("watcher: file failed", "file", filename, "error", runErr) slog.Warn("watcher: file failed", "file", filename, "error", runErr)
@@ -168,28 +232,75 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
} }
// Copy to processed/YYYY-MM-DD/ and leave a .processed marker so we don't re-ingest. // Copy to processed/YYYY-MM-DD/ and leave a .processed marker so we don't re-ingest.
processedDir := filepath.Join(cfg.BrainDir, "raw", "processed", date) // Written through the brain root (hyperguild#158).
if err := os.MkdirAll(processedDir, 0o755); err != nil { if err := copyFromRoot(root, rel, cfg.BrainDir, "raw/processed/"+date, filename, limit); err != nil {
return fmt.Errorf("mkdir processed dir: %w", err)
}
dest := filepath.Join(processedDir, filename)
if _, err := os.Stat(dest); err == nil {
// Archive copy already exists; append timestamp to avoid overwriting.
ext := filepath.Ext(filename)
base := strings.TrimSuffix(filename, ext)
dest = filepath.Join(processedDir, base+"-"+time.Now().UTC().Format("150405")+ext)
}
if err := copyFromRoot(root, rel, 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 changedSince(root, rel, before) {
slog.Error("watcher: failed to write .processed marker", "error", err) slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
return nil
} }
retire(cfg, root, rel, path, ".processed", date, before)
slog.Info("watcher: file processed", "file", filename, "source", source) slog.Info("watcher: file processed", "file", filename, "source", source)
return nil return nil
} }
// changedSince reports whether the note at rel differs (size or mtime) from
// before. A note replaced mid-ingest (Syncthing) was extracted from older
// content than it now holds, so it must not be removed. Limits: a same-size
// rewrite with a restored mtime is not detected (the new bytes then survive
// only in the archive copy), nor a change between this check and Remove.
func changedSince(root *os.Root, rel string, before os.FileInfo) bool {
now, err := root.Lstat(rel)
if err != nil {
return true
}
return now.Size() != before.Size() || !now.ModTime().Equal(before.ModTime())
}
// retire removes an archived original from raw/ (inbox zero). If the removal
// fails it falls back to a marker written through the brain root (#158), and if
// even that fails the note is remembered, with the size and mtime it had, so it
// is not ingested again until it changes.
func retire(cfg Config, root *os.Root, rel, path, markerSuffix, markerBody string, before os.FileInfo) {
rmErr := root.Remove(rel)
if rmErr == nil {
return
}
slog.Error("watcher: failed to remove archived original; writing marker", "file", rel, "error", rmErr)
if err := putBrainFile(cfg.BrainDir, "raw/"+filepath.ToSlash(rel)+markerSuffix, []byte(markerBody)); err != nil {
slog.Error("watcher: failed to write marker; skipping this note until it changes", "file", rel, "error", err)
rememberRefused(path, before)
}
}
// refused remembers notes whose archive copy or marker write was refused,
// by path, with the size and mtime they had, for the life of the process.
var refused sync.Map
type refusedStamp struct {
size int64
mtime time.Time
}
func rememberRefused(path string, info os.FileInfo) {
refused.Store(path, refusedStamp{size: info.Size(), mtime: info.ModTime()})
}
func refusedUnchanged(path string, info os.FileInfo) bool {
v, ok := refused.Load(path)
if !ok {
return false
}
st := v.(refusedStamp)
if st.size == info.Size() && st.mtime.Equal(info.ModTime()) {
return true
}
refused.Delete(path)
return false
}
// 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 {
@@ -214,42 +325,231 @@ func appendWatcherLog(brainDir, filename string, runErr error, date string) erro
entry := fmt.Sprintf("## %s — watcher error\n\n- **File:** %s\n- **Error:** %s\n\n", entry := fmt.Sprintf("## %s — watcher error\n\n- **File:** %s\n- **Error:** %s\n\n",
date, filename, runErr.Error()) date, filename, runErr.Error())
logPath := filepath.Join(brainDir, "log.md") if err := brainfs.AppendFile(brainDir, "log.md", []byte(entry)); err != nil {
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open log: %w", err)
}
if _, err = f.WriteString(entry); err != nil {
f.Close() //nolint:errcheck
return fmt.Errorf("write log: %w", err) return fmt.Errorf("write log: %w", err)
} }
return f.Close() return nil
} }
// copyFromRoot archives a raw/ file read through the root, refusing anything // putBrainFile creates or replaces a brain-relative file through the brain
// that is not a regular file, so a swapped-in symlink cannot copy an outside // root, never through a symlinked directory or file (hyperguild#158).
// file into raw/processed/ or raw/failed/ (hyperguild#153). func putBrainFile(brainDir, rel string, data []byte) error {
func copyFromRoot(root *os.Root, rel, dst string) error { return brainfs.PutFile(brainDir, rel, data)
fi, err := root.Lstat(rel) }
// copyFromRoot archives a raw/ file read through the root into dstDir under a
// name no existing file has (so a note never replaces another's copy: the copy
// is the only one left once the original is retired), refusing anything that is
// not a regular file and anything over max bytes at the read, not just at the
// earlier Lstat (hyperguild#158, #198).
func copyFromRoot(root *os.Root, rel, brainDir, dstDir, filename string, max int64) error {
data, err := readNoteMax(root, rel, max)
if err != nil { if err != nil {
return err return err
} }
return createUnique(brainDir, dstDir, filename, data)
}
// readNoteMax reads a raw/ note through the root, at most max bytes.
func readNoteMax(root *os.Root, rel string, max int64) ([]byte, error) {
fi, err := root.Lstat(rel)
if err != nil {
return nil, err
}
if !fi.Mode().IsRegular() { if !fi.Mode().IsRegular() {
return fmt.Errorf("%s: not a regular file", rel) return nil, fmt.Errorf("%s: not a regular file", rel)
} }
in, _, err := extract.OpenRegular(root, rel) in, _, err := extract.OpenRegular(root, rel)
if err != nil { if err != nil {
return err return nil, err
} }
defer func() { _ = in.Close() }() defer func() { _ = in.Close() }()
out, err := os.Create(dst) data, err := readAllMax(in, max)
if errors.Is(err, errTooLarge) {
return nil, fmt.Errorf("%s: %w (%d bytes)", rel, errTooLarge, max)
}
return data, err
}
// readAllMax reads r but never more than max+1 bytes, so an oversize or growing
// file costs at most that much memory, and reports errTooLarge past max.
func readAllMax(r io.Reader, max int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil { if err != nil {
return err return nil, err
} }
if _, err := io.Copy(out, in); err != nil { if int64(len(data)) > max {
_ = out.Close() return nil, errTooLarge
return err
} }
return out.Close() return data, nil
}
// topDir is the first directory of a raw/-relative path, "" for a top-level file.
func topDir(rel string) string {
if i := strings.IndexByte(filepath.ToSlash(rel), '/'); i > 0 {
return filepath.ToSlash(rel)[:i]
}
return ""
}
// frontmatterWing reads a top-level `wing:` from a note's leading frontmatter,
// "" if there is none. The block counts only if it has a real closing fence (a
// line that is exactly "---"), and an indented `wing:` belongs to some other
// mapping, not to the note.
func frontmatterWing(content string) string {
content = strings.TrimPrefix(content, "\ufeff") // a byte-order mark must not hide the block
content = strings.ReplaceAll(content, "\r\n", "\n")
if !strings.HasPrefix(content, "---\n") {
return ""
}
block, _, found := cutFence(content[4:])
if !found {
return ""
}
for _, line := range strings.Split(block, "\n") {
if line == "" || line[0] == ' ' || line[0] == '\t' {
continue
}
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" {
return strings.Trim(strings.TrimSpace(val), `"'`)
}
}
return ""
}
// parkForReview moves a note the resolver could not place into
// raw/needs-review/, recording the proposal in its frontmatter so a human can
// approve it by moving it into raw/<wing>/. Nothing is extracted or written to
// the wiki.
//
// The parked copy is created through the brain root (real directories only,
// O_EXCL, never overwriting) and is the only copy once the original is retired,
// so the original is removed only after that write succeeds and only if it has
// not changed since it was read: a newer version delivered while the resolver
// ran stays in raw/ for the next poll.
func parkForReview(cfg Config, root *os.Root, rel, path, filename, content string, d wing.Decision, date string, before os.FileInfo) error {
var data []byte
if strings.ToLower(filepath.Ext(filename)) == ".pdf" {
// Binary: park the bytes untouched; the proposal is in log.md only.
var err error
data, err = readNoteMax(root, rel, defaultMaxNoteBytes)
if err != nil {
return fmt.Errorf("park pdf: %w", err)
}
} else {
data = []byte(withProposal(content, d))
}
if err := createParked(cfg.BrainDir, filename, data); err != nil {
return fmt.Errorf("park note: %w", err)
}
if err := appendWatcherLog(cfg.BrainDir, filename, fmt.Errorf("needs review (proposed wing %q): %s", d.Wing, d.Reason), date); err != nil {
slog.Error("watcher: failed to write log entry", "error", err)
}
slog.Info("watcher: parked for review", "file", filename, "proposed_wing", d.Wing, "reason", d.Reason)
if changedSince(root, rel, before) {
slog.Warn("watcher: note changed while it was being resolved; leaving the newer version in raw/", "file", filename)
return nil
}
retire(cfg, root, rel, path, ".processed", date, before)
return nil
}
// proposalKeys are the frontmatter keys the resolver owns in a parked note.
var proposalKeys = map[string]bool{"proposed_wing": true, "proposal_confidence": true, "proposal_reason": true}
// withProposal adds the resolver's proposal to a note's frontmatter, creating
// the block when the note has none. Every value is on one line: the wing only
// if it is a plain wing name (else it appears in the reason alone), strings
// Go-quoted. Any proposal keys the note already carried are dropped first, so a
// note cannot pre-empt the resolver's (frontmatter reads are first-wins).
// Line endings are normalised to LF so a CRLF note keeps one valid block.
func withProposal(content string, d wing.Decision) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
reason := d.Reason
var kv strings.Builder
if d.Wing != "" && brain.Sanitise(d.Wing) == d.Wing {
fmt.Fprintf(&kv, "proposed_wing: %s\n", d.Wing)
} else if d.Wing != "" {
reason = fmt.Sprintf("proposed wing %q is not a plain wing name; %s", d.Wing, reason)
}
fmt.Fprintf(&kv, "proposal_confidence: %.2f\nproposal_reason: %q\n", d.Confidence, reason)
if !strings.HasPrefix(content, "---\n") {
return "---\n" + kv.String() + "---\n" + content
}
block, rest, found := cutFence(content[4:])
if !found {
return "---\n" + kv.String() + "---\n" + content
}
var kept []string
for _, line := range strings.Split(block, "\n") {
if key, _, ok := strings.Cut(line, ":"); ok && proposalKeys[strings.TrimSpace(key)] && !strings.HasPrefix(line, " ") {
continue
}
kept = append(kept, line)
}
return "---\n" + kv.String() + strings.Join(kept, "\n") + "\n---" + rest
}
// cutFence splits s at the first closing fence: a line that is exactly "---".
// "----" or "---x" is not a fence, matching how the frontmatter readers parse.
func cutFence(s string) (block, rest string, found bool) {
if i := strings.Index(s, "\n---\n"); i >= 0 {
return s[:i], s[i+4:], true
}
if strings.HasSuffix(s, "\n---") {
return s[:len(s)-4], "", true
}
return "", "", false
}
// markerBlocks reports whether a marker next to path means "already handled".
// Any non-regular entry at the marker path counts (a dangling symlink must not
// be written through, hyperguild#158). A regular marker describes an EARLIER
// note of the same name, so it does not block this one, and is returned in
// stale, when it is older than the note or dated implausibly far in the future
// (clock skew, a peer): those are removed by the caller. Heuristic by nature:
// a marker at least as new as the note still means "done".
func markerBlocks(path string) (skip bool, stale []string) {
note, err := os.Lstat(path)
if err != nil {
return false, nil
}
for _, suffix := range []string{".processed", ".failed"} {
m, err := os.Lstat(path + suffix)
if err != nil {
continue
}
if !m.Mode().IsRegular() {
return true, nil
}
if m.ModTime().Before(note.ModTime()) || m.ModTime().After(time.Now().Add(markerSkew)) {
stale = append(stale, path+suffix)
continue
}
return true, nil
}
return false, stale
}
// markerSkew is how far ahead of now a marker may be dated before it is not
// believed.
const markerSkew = 10 * time.Minute
// markerWarned remembers notes already reported as held by a marker, so the
// 30-second poll says it once instead of forever.
var markerWarned sync.Map
func warnMarkerOnce(path string) {
if _, seen := markerWarned.LoadOrStore(path, struct{}{}); !seen {
slog.Warn("watcher: note is held by a marker newer than itself and will not be processed; delete the marker or edit the note", "file", path)
}
}
func relOf(rawDir, path string) string {
rel, err := filepath.Rel(rawDir, path)
if err != nil {
return filepath.Base(path)
}
return rel
} }
+330 -10
View File
@@ -4,7 +4,9 @@ package watcher
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -89,13 +91,12 @@ func TestStart_ProcessesFile(t *testing.T) {
} }
require.True(t, found, "file should be copied to processed/") require.True(t, found, "file should be copied to processed/")
// Original file should still exist (copy, not move — keeps Obsidian vault intact). // Inbox zero: once archived, the original leaves raw/ and no marker is
// left behind, so what remains in raw/ is exactly the unprocessed set.
_, err := os.Stat(rawFile) _, err := os.Stat(rawFile)
assert.NoError(t, err, "original file should remain in raw/") assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once archived")
// A .processed marker should exist next to the original.
_, err = os.Stat(rawFile + ".processed") _, err = os.Stat(rawFile + ".processed")
assert.NoError(t, err, ".processed marker should be written") assert.ErrorIs(t, err, os.ErrNotExist, "no .processed marker: the archive copy is the record")
// Wiki page should exist. // Wiki page should exist.
wikiPath := filepath.Join(brainDir, "wiki", "sources", "shape-up-book.md") wikiPath := filepath.Join(brainDir, "wiki", "sources", "shape-up-book.md")
@@ -142,13 +143,11 @@ func TestStart_MovesToFailedOnError(t *testing.T) {
} }
require.True(t, found, "file should be copied to failed/") require.True(t, found, "file should be copied to failed/")
// Original file should still exist (copy, not move — keeps Obsidian vault intact). // Inbox zero: a quarantined original leaves raw/ too, no marker beside it.
_, err := os.Stat(rawFile) _, err := os.Stat(rawFile)
assert.NoError(t, err, "original file should remain in raw/") assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once quarantined")
// A .failed marker should exist next to the original.
_, err = os.Stat(rawFile + ".failed") _, err = os.Stat(rawFile + ".failed")
assert.NoError(t, err, ".failed marker should be written") assert.ErrorIs(t, err, os.ErrNotExist, "no .failed marker: failed/ is the record")
// log.md should contain a watcher error entry. // log.md should contain a watcher error entry.
logContent, err := os.ReadFile(filepath.Join(brainDir, "log.md")) logContent, err := os.ReadFile(filepath.Join(brainDir, "log.md"))
@@ -390,3 +389,324 @@ func TestProcessDir_DoesNotHangUnderAFIFOSwapRace(t *testing.T) {
t.Fatalf("the watcher hung on a swapped-in FIFO after %d iterations", iters.Load()) t.Fatalf("the watcher hung on a swapped-in FIFO after %d iterations", iters.Load())
} }
} }
// A note whose removal AND marker write both fail (read-only raw/) must not be
// re-ingested on every poll: each pass would write another archive copy.
func TestProcessDir_DoesNotReingestANoteItCannotRetire(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "stuck.md"), []byte("Content."), 0o644))
// processed/ must exist and stay writable; only raw/ itself is locked.
require.NoError(t, os.MkdirAll(filepath.Join(rawDir, "processed"), 0o755))
require.NoError(t, os.Chmod(rawDir, 0o555))
t.Cleanup(func() { _ = os.Chmod(rawDir, 0o755) })
var calls atomic.Int32
cfg := Config{
BrainDir: brainDir,
Pipeline: pipeline.Config{
Complete: func(ctx context.Context, s, u string) (string, error) {
calls.Add(1)
return successComplete(pipeline.RawPage{Title: "Stuck", Type: "source", Subtype: "article", Content: "x\n"})(ctx, s, u)
},
Schema: "# Schema",
},
}
for i := 0; i < 3; i++ {
processDir(context.Background(), cfg, "2026-10-19")
}
// The LLM count alone proves nothing: pipeline.Run's source-hash check skips
// extraction on an unchanged note, so a re-run is invisible there. What a
// re-run does leave behind is another archive copy, every poll.
archived, err := filepath.Glob(filepath.Join(rawDir, "processed", "2026-10-19", "*"))
require.NoError(t, err)
assert.Len(t, archived, 1, "a note that cannot be retired is archived once, not once per poll")
assert.Equal(t, int32(1), calls.Load())
}
// If the archive copy cannot be written, the original is the only copy left:
// it must stay in raw/.
func TestProcessDir_KeepsTheOriginalWhenTheArchiveCopyFails(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
note := filepath.Join(rawDir, "keep-me.md")
require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644))
// The dated archive dir exists but is read-only: MkdirAll succeeds, the
// copy itself (os.Create) fails.
dated := filepath.Join(rawDir, "processed", "2026-10-19")
require.NoError(t, os.MkdirAll(dated, 0o755))
require.NoError(t, os.Chmod(dated, 0o555))
t.Cleanup(func() { _ = os.Chmod(dated, 0o755) })
cfg := Config{
BrainDir: brainDir,
Pipeline: pipeline.Config{
Complete: successComplete(pipeline.RawPage{Title: "Keep Me", Type: "source", Subtype: "article", Content: "x\n"}),
Schema: "# Schema",
},
}
errs := processDir(context.Background(), cfg, "2026-10-19")
assert.NotEmpty(t, errs, "the failed archive is reported")
_, err := os.Stat(note)
assert.NoError(t, err, "original stays in raw/ when it could not be archived")
}
// If the note is replaced (e.g. by Syncthing) while it is being ingested, the
// wiki page was extracted from the old content; removing the original would
// leave the new content only in the archive and never re-ingest it. Observes
// size/mtime change only: a same-size rewrite with a restored mtime is not caught.
func TestProcessDir_KeepsANoteThatChangedWhileItWasBeingIngested(t *testing.T) {
brainDir := setupBrainDir(t)
note := filepath.Join(brainDir, "raw", "edited.md")
require.NoError(t, os.WriteFile(note, []byte("old"), 0o644))
cfg := Config{
BrainDir: brainDir,
Pipeline: pipeline.Config{
Complete: func(ctx context.Context, s, u string) (string, error) {
require.NoError(t, os.WriteFile(note, []byte("newer and longer content"), 0o644))
return successComplete(pipeline.RawPage{Title: "Edited", Type: "source", Subtype: "article", Content: "x\n"})(ctx, s, u)
},
Schema: "# Schema",
},
}
processDir(context.Background(), cfg, "2026-10-19")
got, err := os.ReadFile(note)
require.NoError(t, err, "a note that changed during ingest must stay in raw/")
assert.Equal(t, "newer and longer content", string(got))
}
// hyperguild#198: an oversized note must not be read into memory. It is left in
// raw/ (visible, not lost), reported once, and not retried until it changes.
func TestProcessDir_ANoteOverTheSizeCapIsRefusedOnceAndKept(t *testing.T) {
brainDir := setupBrainDir(t)
big := filepath.Join(brainDir, "raw", "big.md")
require.NoError(t, os.WriteFile(big, []byte(strings.Repeat("x", 2048)), 0o644))
var calls atomic.Int32
cfg := Config{
BrainDir: brainDir,
MaxNoteBytes: 1024,
Pipeline: pipeline.Config{
Complete: func(ctx context.Context, s, u string) (string, error) {
calls.Add(1)
return successComplete(pipeline.RawPage{Title: "Big", Type: "source", Content: "x\n"})(ctx, s, u)
},
Schema: "# Schema",
},
}
first := processDir(context.Background(), cfg, "2026-10-20")
require.NotEmpty(t, first, "the refusal is reported")
assert.Contains(t, first[0].Error(), "over the 1024 byte cap")
second := processDir(context.Background(), cfg, "2026-10-20")
assert.Empty(t, second, "and reported once, not every poll")
assert.Zero(t, calls.Load(), "no extraction")
_, err := os.Stat(big)
assert.NoError(t, err, "the note stays in raw/")
entries, _ := os.ReadDir(filepath.Join(brainDir, "raw", "processed"))
assert.Empty(t, entries, "nothing was archived")
}
// hyperguild#199: a name near NAME_MAX cannot take a ".processed" suffix. Under
// inbox zero the marker is only the fallback when removal fails, and any failure
// there is remembered, so the note is still not handled every poll.
func TestProcessDir_ALongNameWhoseMarkerCannotBeWrittenIsNotHandledEveryPoll(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
name := strings.Repeat("a", 250) + ".md" // 253 bytes; +".processed" exceeds 255
require.NoError(t, os.WriteFile(filepath.Join(rawDir, name), []byte("Content."), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(rawDir, "processed"), 0o755))
require.NoError(t, os.Chmod(rawDir, 0o555)) // removal fails, so the marker fallback runs
t.Cleanup(func() { _ = os.Chmod(rawDir, 0o755) })
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{
Complete: successComplete(pipeline.RawPage{Title: "Long", Type: "source", Content: "x\n"}), Schema: "# Schema"}}
for i := 0; i < 3; i++ {
errs := processDir(context.Background(), cfg, "2026-10-20")
// Without the remember step the second poll tries to archive a second
// copy under a name that is too long and errors, every poll.
assert.Empty(t, errs, "poll %d handled the note again", i)
}
archived, err := filepath.Glob(filepath.Join(rawDir, "processed", "2026-10-20", "*"))
require.NoError(t, err)
assert.Len(t, archived, 1)
}
// Three notes named n.md in three folders, one poll. The archive and failed/
// copies are the only copy once the originals are retired, so none may replace
// another.
func TestProcessDir_SameNamedNotesAreAllArchivedNoneOverwritten(t *testing.T) {
for _, outcome := range []string{"processed", "failed"} {
t.Run(outcome, func(t *testing.T) {
brainDir := setupBrainDir(t)
for _, d := range []string{"a", "b", "c"} {
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", d), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", d, "n.md"), []byte("CONTENT-"+d), 0o644))
}
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
Complete: successComplete(pipeline.RawPage{Title: "N", Type: "source", Content: "x\n"})}}
if outcome == "failed" {
cfg.Pipeline.Complete = errorComplete
}
processDir(context.Background(), cfg, "2026-10-20")
var all string
files, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*", "*"))
more, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*"))
for _, f := range append(files, more...) {
if b, err := os.ReadFile(f); err == nil {
all += string(b)
}
}
for _, d := range []string{"a", "b", "c"} {
assert.Contains(t, all, "CONTENT-"+d, "the copy of note %s was overwritten", d)
}
})
}
}
// A marker older than the note describes an earlier note of the same name; it
// must not hide the new one, silently, forever (the legacy .processed markers
// from before inbox zero are exactly this).
func TestProcessDir_AStaleMarkerDoesNotHideANewerNoteOfTheSameName(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
marker := filepath.Join(rawDir, "idea.md.processed")
require.NoError(t, os.WriteFile(marker, []byte("2026-06-16"), 0o644))
old := time.Now().Add(-48 * time.Hour)
require.NoError(t, os.Chtimes(marker, old, old))
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea, same file name."), 0o644))
var calls atomic.Int32
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
Complete: func(ctx context.Context, s, u string) (string, error) {
calls.Add(1)
return successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})(ctx, s, u)
}}}
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.Equal(t, int32(1), calls.Load(), "the new note is processed")
_, err := os.Stat(filepath.Join(rawDir, "idea.md"))
assert.ErrorIs(t, err, os.ErrNotExist, "and retired")
_, err = os.Lstat(marker)
assert.ErrorIs(t, err, os.ErrNotExist, "the stale marker is cleaned up")
}
func TestProcessDir_AMarkerNewerThanTheNoteStillMeansDone(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
note := filepath.Join(rawDir, "stuck.md")
require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644))
past := time.Now().Add(-time.Hour)
require.NoError(t, os.Chtimes(note, past, past))
require.NoError(t, os.WriteFile(note+".processed", []byte("d"), 0o644)) // newer than the note
var calls atomic.Int32
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
Complete: func(context.Context, string, string) (string, error) { calls.Add(1); return "[]", nil }}}
processDir(context.Background(), cfg, "2026-10-20")
assert.Zero(t, calls.Load(), "a fallback marker written after the note still stops reprocessing")
}
// The archive copy is a second read of the note; the cap must hold there too,
// not only at the earlier Lstat (a note can grow in between).
func TestReadNoteMax(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "n.md"), []byte(strings.Repeat("x", 100)), 0o644))
root, err := os.OpenRoot(dir)
require.NoError(t, err)
defer func() { _ = root.Close() }()
got, err := readNoteMax(root, "n.md", 100)
require.NoError(t, err)
assert.Len(t, got, 100, "exactly at the cap is allowed")
_, err = readNoteMax(root, "n.md", 99)
require.Error(t, err)
assert.ErrorIs(t, err, errTooLarge)
}
// countingReader reports how much was pulled from it: an endless file must be
// abandoned at max+1 bytes, not slurped.
type countingReader struct{ n int64 }
func (c *countingReader) Read(p []byte) (int, error) {
if c.n > 8<<20 { // a regression must fail the test, not exhaust memory
return 0, errors.New("endless reader drained")
}
for i := range p {
p[i] = 'x'
}
c.n += int64(len(p))
return len(p), nil
}
func TestReadAllMax_StopsReadingAtTheCap(t *testing.T) {
c := &countingReader{}
_, err := readAllMax(c, 1000)
require.ErrorIs(t, err, errTooLarge)
assert.LessOrEqual(t, c.n, int64(1000+1+32*1024), "pulled far more than the cap from an endless reader: %d", c.n)
}
// From the second review of #210: a marker dated in the future (clock skew,
// Syncthing) must not hide the note for good, and when a marker does block it
// has to say so, once.
func TestProcessDir_AFutureDatedMarkerDoesNotHideTheNote(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
marker := filepath.Join(rawDir, "idea.md.processed")
require.NoError(t, os.WriteFile(marker, []byte("x"), 0o644))
future := time.Now().Add(72 * time.Hour)
require.NoError(t, os.Chtimes(marker, future, future))
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea."), 0o644))
var calls atomic.Int32
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
Complete: func(ctx context.Context, s, u string) (string, error) {
calls.Add(1)
return successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})(ctx, s, u)
}}}
processDir(context.Background(), cfg, "2026-10-20")
assert.Equal(t, int32(1), calls.Load(), "a marker from the future is not evidence the note was handled")
}
func TestProcessDir_BothStaleMarkersAreRemoved(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
old := time.Now().Add(-48 * time.Hour)
for _, suffix := range []string{".processed", ".failed"} {
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"+suffix), []byte("x"), 0o644))
require.NoError(t, os.Chtimes(filepath.Join(rawDir, "idea.md"+suffix), old, old))
}
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea."), 0o644))
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
Complete: successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})}}
processDir(context.Background(), cfg, "2026-10-20")
for _, suffix := range []string{".processed", ".failed"} {
_, err := os.Lstat(filepath.Join(rawDir, "idea.md"+suffix))
assert.ErrorIs(t, err, os.ErrNotExist, "orphan %s marker left behind", suffix)
}
}
func TestProcessDir_AMarkerThatBlocksANoteIsReportedOnce(t *testing.T) {
var logs strings.Builder
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
note := filepath.Join(rawDir, "held.md")
require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644))
past := time.Now().Add(-time.Hour)
require.NoError(t, os.Chtimes(note, past, past))
require.NoError(t, os.WriteFile(note+".processed", []byte("d"), 0o644)) // newer: blocks
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema", Complete: errorComplete}}
for i := 0; i < 3; i++ {
processDir(context.Background(), cfg, "2026-10-20")
}
assert.Equal(t, 1, strings.Count(logs.String(), "held by a marker"), "said once, not every poll: %s", logs.String())
}
@@ -0,0 +1,358 @@
package watcher
import (
"context"
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
)
func wingTestCfg(t *testing.T, mk func(brainDir string) wing.Placer, llmCalls *atomic.Int32) (Config, string) {
t.Helper()
brainDir := setupBrainDir(t)
cfg := Config{
BrainDir: brainDir,
Pipeline: pipeline.Config{
Schema: "# Schema",
Complete: func(ctx context.Context, s, u string) (string, error) {
llmCalls.Add(1)
return successComplete(pipeline.RawPage{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"})(ctx, s, u)
},
},
}
if mk != nil {
cfg.Resolver = mk(brainDir)
}
return cfg, brainDir
}
// router is the production Router with a client-content marker.
func router(brainDir string) wing.Placer {
return wing.Router{BrainDir: brainDir, Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
}
func makeWing(t *testing.T, brainDir, name string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", name, "facts"), 0o755))
}
func put(t *testing.T, brainDir, rel, body string) {
t.Helper()
full := filepath.Join(brainDir, "raw", rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
}
func has(brainDir, rel string) bool {
_, err := os.Stat(filepath.Join(brainDir, rel))
return err == nil
}
func mustJSONPages(pages []pipeline.RawPage) string {
b, err := json.Marshal(pages)
if err != nil {
panic(err)
}
return string(b)
}
func parkedBody(t *testing.T, brainDir, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", name))
require.NoError(t, err)
return string(b)
}
func linesStartingWith(s, prefix string) int {
n := 0
for _, l := range strings.Split(s, "\n") {
if strings.HasPrefix(l, prefix) {
n++
}
}
return n
}
func TestProcessDir_ASubfolderNamesTheWing(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "radar")
put(t, brainDir, "radar/dark-factory.md", "A talk about factories.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.True(t, has(brainDir, "wiki/radar/sources/dark-factory.md"), "lands in the named wing")
assert.False(t, has(brainDir, "raw/radar/dark-factory.md"), "inbox zero holds for subfolders too")
}
func TestProcessDir_ADeliberateSubfolderIntoAnyExistingWingPlaces(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "rikatillsammans")
put(t, brainDir, "rikatillsammans/meeting.md", "Notes the owner put there on purpose.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.True(t, has(brainDir, "wiki/rikatillsammans/sources/meeting.md"),
"a folder is the owner's deliberate choice; extraction runs on the local model")
}
func TestProcessDir_ANoteTheRouterCannotPlaceIsParkedNotIngested(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
put(t, brainDir, "kickoff.md", "Some note about the ACME-CLIENT kickoff.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.Zero(t, llmCalls.Load(), "no extraction runs for an unplaced note")
assert.False(t, has(brainDir, "raw/kickoff.md"), "leaves the top of raw/")
parked := parkedBody(t, brainDir, "kickoff.md")
assert.Contains(t, parked, "proposal_reason:")
assert.Contains(t, parked, "client-content marker")
assert.Contains(t, parked, "ACME-CLIENT kickoff", "the note body is preserved")
}
func TestProcessDir_ATypoedWingFolderIsParkedAndNeverCreated(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "homelab")
put(t, brainDir, "hoemlab/n.md", "Note.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.False(t, has(brainDir, "wiki/hoemlab"))
assert.True(t, has(brainDir, "raw/needs-review/n.md"))
assert.Zero(t, llmCalls.Load())
}
func TestProcessDir_ParkedNotesAreNotReprocessed(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
put(t, brainDir, "needs-review/waiting.md", "---\nproposed_wing: radar\n---\nA parked note.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.Zero(t, llmCalls.Load(), "needs-review/ is a human queue, like rejected/")
assert.True(t, has(brainDir, "raw/needs-review/waiting.md"))
}
func TestProcessDir_WithoutAResolverTheRetiredLayoutIsUnchanged(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, nil, &llmCalls)
put(t, brainDir, "plain.md", "Plain note.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.True(t, has(brainDir, "wiki/sources/plain.md"))
}
// The reason comes from file and folder names and ends up in frontmatter: a
// newline in it must not be able to start a new key (PR #147: a title once
// forged `origin:`).
func TestProcessDir_AFolderNameCannotForgeFrontmatterKeys(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
put(t, brainDir, "radar\nproposal_confidence: 0.99\nwing: homelab/forge.md", "Note.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
entries, err := os.ReadDir(filepath.Join(brainDir, "raw", "needs-review"))
require.NoError(t, err)
require.Len(t, entries, 1)
b := parkedBody(t, brainDir, entries[0].Name())
assert.Equal(t, 1, linesStartingWith(b, "proposal_confidence:"), "exactly one confidence key at line start")
assert.Equal(t, 0, linesStartingWith(b, "wing:"), "no injected wing key")
assert.Equal(t, 0, linesStartingWith(b, "proposed_wing:"), "a non-plain wing name is never a proposal")
}
func TestProcessDir_ANotesOwnProposalKeysDoNotOutrankTheRouters(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
put(t, brainDir, "n.md", "---\nproposed_wing: homelab\nproposal_confidence: 1.00\nproposal_reason: \"self-approved\"\n---\nACME-CLIENT body.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
b := parkedBody(t, brainDir, "n.md")
assert.NotContains(t, b, "self-approved")
assert.Equal(t, 1, linesStartingWith(b, "proposal_reason:"))
assert.Equal(t, 0, linesStartingWith(b, "proposed_wing:"))
}
func TestProcessDir_ACRLFNoteWithAWingKeyIsStillRead(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "radar")
put(t, brainDir, "n.md", "---\r\nwing: radar\r\n---\r\nBody.\r\n")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.True(t, has(brainDir, "wiki/radar/sources/n.md"), "the declared wing is honoured")
}
// editingPlacer rewrites the note while the placement decision is being made,
// then parks it: the realistic window is a slow decision with Syncthing
// delivering a newer version meanwhile.
type editingPlacer struct{ edit func() }
func (e editingPlacer) Resolve(context.Context, wing.Note) wing.Decision {
e.edit()
return wing.Decision{Source: wing.Exception, Reason: "test: parked after an edit"}
}
func TestProcessDir_ANoteEditedWhileItIsBeingPlacedIsNotDeleted(t *testing.T) {
var llmCalls atomic.Int32
var notePath string
cfg, brainDir := wingTestCfg(t, func(string) wing.Placer {
return editingPlacer{edit: func() {
require.NoError(t, os.WriteFile(notePath, []byte("NEWER and longer version"), 0o644))
}}
}, &llmCalls)
notePath = filepath.Join(brainDir, "raw", "n.md")
put(t, brainDir, "n.md", "ORIGINAL")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
got, err := os.ReadFile(notePath)
require.NoError(t, err, "the edited note must still be in raw/")
assert.Equal(t, "NEWER and longer version", string(got))
}
// A planted symlink at raw/needs-review must not redirect a parked note out of
// the brain, nor lose it from the review queue.
func TestProcessDir_AParkedNoteIsNeverWrittenThroughASymlinkedNeedsReview(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
outside := t.TempDir()
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "needs-review")))
put(t, brainDir, "n.md", "ACME-CLIENT note.")
errs := processDir(context.Background(), cfg, "2026-10-20")
assert.NotEmpty(t, errs, "parking is refused, loudly")
entries, _ := os.ReadDir(outside)
assert.Empty(t, entries, "nothing was written through the symlink")
assert.True(t, has(brainDir, "raw/n.md"), "the note stays where it was")
}
func TestWithProposal_ANonFenceDashLineDoesNotCloseTheBlock(t *testing.T) {
out := withProposal("---\nfoo: bar\n----\nBody", wing.Decision{Wing: "radar", Confidence: 0.4, Reason: "r"})
lines := strings.Split(out, "\n")
require.Equal(t, "---", lines[0])
closing := -1
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
closing = i
break
}
}
require.Positive(t, closing, "the proposal block is closed by a real fence")
assert.Contains(t, strings.Join(lines[:closing], "\n"), "proposal_reason:")
assert.NotContains(t, strings.Join(lines[:closing], "\n"), "foo: bar", "the unfenced note is body, not part of the block")
}
// The parked copy is the only copy once the original is retired, so two notes
// that share a name must both survive.
func TestProcessDir_SameNamedNotesAreAllParkedNoneOverwritten(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
for i, body := range []string{"ONE", "TWO", "THREE", "FOUR"} {
put(t, brainDir, filepath.Join("typo"+string(rune('1'+i)), "x.md"), body)
}
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
files, err := filepath.Glob(filepath.Join(brainDir, "raw", "needs-review", "*.md"))
require.NoError(t, err)
var all string
for _, f := range files {
b, _ := os.ReadFile(f)
all += string(b)
}
for _, body := range []string{"ONE", "TWO", "THREE", "FOUR"} {
assert.Contains(t, all, body, "note %s must not have been overwritten", body)
}
assert.Len(t, files, 4)
}
// Syncthing can bring a removed note back (an offline peer, a restored backup).
// The source-hash drift check must keep that from costing a second extraction.
func TestProcessDir_ANoteThatReappearsUnchangedIsNotExtractedAgain(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "radar")
put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
require.Equal(t, int32(1), llmCalls.Load(), "positive control: the first drop is extracted")
require.False(t, has(brainDir, "raw/radar/talk.md"))
put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.") // the peer sends it back
require.Empty(t, processDir(context.Background(), cfg, "2026-10-21"))
assert.Equal(t, int32(1), llmCalls.Load(), "an unchanged source is not extracted twice")
assert.False(t, has(brainDir, "raw/radar/talk.md"), "and inbox zero still holds")
}
func TestProcessDir_ANoteThatReappearsChangedIsExtractedAgain(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "radar")
put(t, brainDir, "radar/talk.md", "Version one.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
put(t, brainDir, "radar/talk.md", "Version two, edited.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-21"))
assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up")
}
func TestFrontmatterWing(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"plain key", "---\nwing: radar\n---\nbody", "radar"},
{"quoted", "---\nwing: \"radar\"\n---\nbody", "radar"},
{"CRLF", "---\r\nwing: radar\r\n---\r\nbody", "radar"},
{"no frontmatter", "wing: radar\nbody", ""},
{"an indented key belongs to another mapping", "---\nmeta:\n wing: radar\n---\nbody", ""},
{"a key before a non-fence dash line is not frontmatter", "---\nwing: radar\n----\nbody", ""},
{"no closing fence", "---\nwing: radar\nbody", ""},
{"closing fence at end of input", "---\nwing: radar\n---", "radar"},
{"another key first", "---\ntitle: x\nwing: radar\n---\nbody", "radar"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, frontmatterWing(tt.in))
})
}
}
func TestFrontmatterWing_ABOMDoesNotHideTheKey(t *testing.T) {
assert.Equal(t, "homelab", frontmatterWing("\ufeff---\nwing: homelab\n---\nbody"))
}
// A note that produced nothing must not leave the inbox as "processed".
func TestProcessDir_AWingIngestThatWritesNothingFailsTheNoteVisibly(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
cfg.Pipeline.Complete = func(context.Context, string, string) (string, error) { return "[]", nil }
put(t, brainDir, "empty-result.md", "A note the model returned nothing for.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.True(t, has(brainDir, "raw/failed/empty-result.md"), "kept where a human looks")
processed, _ := filepath.Glob(filepath.Join(brainDir, "raw", "processed", "*", "*"))
assert.Empty(t, processed, "never archived as processed")
logb, err := os.ReadFile(filepath.Join(brainDir, "log.md"))
require.NoError(t, err)
assert.Contains(t, string(logb), "no source page")
}
@@ -0,0 +1,190 @@
package watcher
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
)
func okPipeline() pipeline.Config {
return pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
b, _ := json.Marshal([]pipeline.RawPage{{Title: "X", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}})
return string(b), nil
}, Schema: "# Schema"}
}
func failingPipeline() pipeline.Config {
return pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
return "", errors.New("model down")
}, Schema: "# Schema"}
}
// hyperguild#158: the watcher wrote the archive copy under raw/processed or
// raw/failed, and its log.md entry, by path, so a symlinked archive
// directory or log redirected the write elsewhere in the brain or outside
// it. (A pre-planted marker symlink makes the watcher skip the note, so the
// marker write itself is reachable only by a race.)
func TestProcessDirDoesNotWriteThroughASymlinkedArchiveDir(t *testing.T) {
for name, pcfg := range map[string]pipeline.Config{"processed": okPipeline(), "failed": failingPipeline()} {
t.Run(name, func(t *testing.T) {
brainDir := setupBrainDir(t)
outside := t.TempDir()
victim := filepath.Join(brainDir, "wiki", "concepts", "v.md")
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", name)))
_ = processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pcfg}, "2026-10-05")
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched", string(got))
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents, "the archive copy was written outside the brain")
})
}
}
func TestProcessDirWritesTheArchiveInRealDirectoriesAndRetiresTheOriginal(t *testing.T) {
brainDir := setupBrainDir(t)
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
errs := processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: okPipeline()}, "2026-10-05")
assert.Empty(t, errs)
// Inbox zero (v0.25.0): no marker is written beside a note that was removed.
_, err := os.Stat(filepath.Join(brainDir, "raw", "n.md.processed"))
assert.ErrorIs(t, err, os.ErrNotExist, "a marker is only the fallback when the removal fails")
_, err = os.Stat(filepath.Join(brainDir, "raw", "n.md"))
assert.ErrorIs(t, err, os.ErrNotExist, "positive control: the original is retired")
_, err = os.Stat(filepath.Join(brainDir, "raw", "processed", "2026-10-05", "n.md"))
assert.NoError(t, err, "positive control: archive copy written")
}
// The log.md entry for a failed note is appended through the brain root: with
// a real raw/failed (so the archive copy succeeds and the log is reached), a
// symlinked log.md is not written through.
func TestProcessDirDoesNotAppendTheLogThroughASymlink(t *testing.T) {
brainDir := setupBrainDir(t)
victim := filepath.Join(brainDir, "wiki", "concepts", "v.md")
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("wiki", "concepts", "v.md"), filepath.Join(brainDir, "log.md")))
_ = processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: failingPipeline()}, "2026-10-05")
_, err := os.Stat(filepath.Join(brainDir, "raw", "failed", "n.md"))
require.NoError(t, err, "positive control: the archive copy was written, so the log append was reached")
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched", string(got), "the log was appended through a symlink")
}
// A marker path holding anything (here a dangling symlink) counts as a
// marker: the note is skipped and nothing is created at the link's target.
// (os.Stat followed the link, saw nothing, and processed the note again.)
func TestProcessDirTreatsAnyEntryAtTheMarkerPathAsAMarker(t *testing.T) {
for _, kind := range []string{"processed", "failed"} {
t.Run(kind, func(t *testing.T) {
brainDir := setupBrainDir(t)
outside := filepath.Join(t.TempDir(), "created")
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "n.md."+kind)))
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"}}
_ = processDir(context.Background(), cfg, "2026-10-05")
assert.Zero(t, calls, "the note was processed despite an entry at its marker path")
_, err := os.Lstat(outside)
assert.True(t, os.IsNotExist(err), "a file was created at the marker link's target")
})
}
}
// A link planted at the marker path while pipeline.Run is in flight (in
// production, an LLM call) ends up with its target untouched, and the note is
// processed once, even after the link is removed (as symlinkguard does). Under
// inbox zero the original is removed, so the marker write that this scenario
// was written to redirect is never reached: this test does NOT pin that the
// marker-fallback write goes through the brain root (see the PR description).
func TestProcessDirLeavesAMarkerLinkPlantedDuringTheRunUntouchedAndProcessesTheNoteOnce(t *testing.T) {
for _, kind := range []string{"processed", "failed"} {
for _, target := range []string{"outside", "in-brain"} {
t.Run(kind+"/"+target, func(t *testing.T) {
brainDir := setupBrainDir(t)
raw := filepath.Join(brainDir, "raw")
marker := filepath.Join(raw, "n.md."+kind)
require.NoError(t, os.WriteFile(filepath.Join(raw, "n.md"), []byte("note"), 0o644))
// The in-brain target is relative, so os.Root itself would
// follow it; only the regular-file check refuses it.
victim := filepath.Join(raw, "victim")
link := filepath.Join(t.TempDir(), "created")
if target == "in-brain" {
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
link = "victim"
}
ok := okPipeline().Complete
calls := 0
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{Schema: "# Schema",
Complete: func(ctx context.Context, sys, user string) (string, error) {
calls++
require.NoError(t, os.Symlink(link, marker))
if kind == "failed" {
return "", errors.New("model down")
}
return ok(ctx, sys, user)
}}}
for i := 0; i < 3; i++ {
_ = processDir(context.Background(), cfg, "2026-10-05")
_ = os.Remove(marker)
}
assert.Equal(t, 1, calls, "the note was retried after the link was removed")
if target == "in-brain" {
got, err := os.ReadFile(victim)
require.NoError(t, err)
assert.Equal(t, "untouched", string(got), "the marker was written through the link")
} else {
_, err := os.Lstat(link)
assert.True(t, os.IsNotExist(err), "the marker was written through the link")
}
})
}
}
}
// A refused archive copy must not retry every interval: the note is
// remembered and skipped until it changes (a symlinked raw/failed cost one
// LLM call per round before).
func TestProcessDirDoesNotRetryANoteWhoseArchiveCopyIsRefused(t *testing.T) {
brainDir := setupBrainDir(t)
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brainDir, "raw", "failed")))
calls := 0
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
Complete: func(context.Context, string, string) (string, error) { calls++; return "", errors.New("model down") }, Schema: "# Schema"}}
for i := 0; i < 3; i++ {
_ = processDir(context.Background(), cfg, "2026-10-05")
}
assert.Equal(t, 1, calls, "the refused note was retried every round")
// Once the note changes it is tried again.
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note, edited"), 0o644))
future := time.Now().Add(2 * time.Second)
require.NoError(t, os.Chtimes(filepath.Join(brainDir, "raw", "n.md"), future, future))
_ = processDir(context.Background(), cfg, "2026-10-05")
assert.Equal(t, 2, calls, "positive control: a changed note is tried again")
}
// A note whose name has a backslash is legal on Linux and must be processed
// (cleanRel refused it, so it was never archived or marked).
func TestProcessDirProcessesANoteWithABackslashInItsName(t *testing.T) {
brainDir := setupBrainDir(t)
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "a\\b.md"), []byte("note"), 0o644))
errs := processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: okPipeline()}, "2026-10-05")
assert.Empty(t, errs)
_, err := os.Stat(filepath.Join(brainDir, "raw", "processed", "2026-10-05", "a\\b.md"))
assert.NoError(t, err, "archived")
_, err = os.Stat(filepath.Join(brainDir, "raw", "a\\b.md"))
assert.ErrorIs(t, err, os.ErrNotExist, "and retired")
}
+56
View File
@@ -0,0 +1,56 @@
package wiki
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func put(t *testing.T, dir, rel string) {
t.Helper()
full := filepath.Join(dir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte("---\ntitle: x\n---\n"), 0o644))
}
func TestFindSlugs(t *testing.T) {
dir := t.TempDir()
put(t, dir, "wiki/sources/legacy-src.md")
put(t, dir, "wiki/concepts/a-concept.md")
put(t, dir, "wiki/entities/an-entity.md")
put(t, dir, "wiki/homelab/failures/in-a-hall.md")
put(t, dir, "wiki/web-clip/sources/a-clip.md")
// One slug in two places: the first hit must not hide the second.
put(t, dir, "wiki/sources/shared-slug.md")
put(t, dir, "wiki/entities/shared-slug.md")
put(t, dir, "wiki/aaa/facts/shared-slug.md")
tests := []struct {
slug string
want []string
}{
{"legacy-src", []string{"wiki/sources/legacy-src.md"}},
{"a-concept", []string{"wiki/concepts/a-concept.md"}},
{"an-entity", []string{"wiki/entities/an-entity.md"}},
{"in-a-hall", []string{"wiki/homelab/failures/in-a-hall.md"}},
{"a-clip", []string{"wiki/web-clip/sources/a-clip.md"}},
{"shared-slug", []string{"wiki/aaa/facts/shared-slug.md", "wiki/entities/shared-slug.md", "wiki/sources/shared-slug.md"}},
{"nothing-here", nil},
}
for _, tt := range tests {
t.Run(tt.slug, func(t *testing.T) {
got, err := FindSlugs(dir, tt.slug)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestFindSlugs_NoWikiDirFindsNothing(t *testing.T) {
got, err := FindSlugs(t.TempDir(), "x")
require.NoError(t, err)
assert.Empty(t, got)
}
+13 -8
View File
@@ -3,10 +3,9 @@ package wiki
import ( import (
"fmt" "fmt"
"os"
"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"
) )
@@ -16,6 +15,11 @@ 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
@@ -34,7 +38,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(brainDir, e) summary := pageFirstSentence(bfs, 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 {
@@ -44,13 +48,14 @@ func RebuildIndex(brainDir, date string) error {
sb.WriteString("\n") sb.WriteString("\n")
} }
dest := filepath.Join(brainDir, "wiki", "index.md") // Written through the brain root, never through a symlink (#158).
return os.WriteFile(dest, []byte(sb.String()), 0o644) return bfs.PutRegular("wiki/index.md", []byte(sb.String()))
} }
func pageFirstSentence(brainDir string, e Entry) string { // pageFirstSentence reads the page through the brain root, regular files
path := filepath.Join(brainDir, "wiki", string(e.Type), e.Slug+".md") // only (hyperguild#157).
content, err := os.ReadFile(path) func pageFirstSentence(bfs *brainfs.Brain, e Entry) string {
content, err := bfs.ReadRegular("wiki/" + string(e.Type) + "/" + e.Slug + ".md")
if err != nil { if err != nil {
return "" return ""
} }
+119 -15
View File
@@ -3,36 +3,46 @@ package wiki
import ( import (
"bufio" "bufio"
"bytes"
"errors"
"fmt" "fmt"
"os" "io/fs"
"path/filepath" "sort"
"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. // Missing subdirectories are silently skipped. Pages are listed and read
// through the brain root, regular files only (hyperguild#157); a type
// directory that is a symlink is an error.
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 := filepath.Join(brainDir, "wiki", string(pt)) dir := "wiki/" + string(pt)
entries, err := os.ReadDir(dir) names, err := bfs.ReadDirRegular(dir)
if os.IsNotExist(err) { if errors.Is(err, fs.ErrNotExist) {
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 _, e := range entries { for _, name := range names {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { if !strings.HasSuffix(name, ".md") {
continue continue
} }
slug := strings.TrimSuffix(e.Name(), ".md") slug := strings.TrimSuffix(name, ".md")
path := filepath.Join(dir, e.Name()) title, aliases := readFrontmatter(bfs, dir+"/"+name, slug)
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})
} }
} }
@@ -41,15 +51,14 @@ 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(path, fallbackSlug string) (title string, aliases []string) { func readFrontmatter(bfs *brainfs.Brain, rel, fallbackSlug string) (title string, aliases []string) {
title = fallbackSlug title = fallbackSlug
f, err := os.Open(path) content, err := bfs.ReadRegular(rel)
if err != nil { if err != nil {
return return
} }
defer f.Close() //nolint:errcheck
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(bytes.NewReader(content))
inFM := false inFM := false
inAliases := false inAliases := false
for scanner.Scan() { for scanner.Scan() {
@@ -88,3 +97,98 @@ func readFrontmatter(path, fallbackSlug string) (title string, aliases []string)
} }
return return
} }
// LoadWingInventory returns what an ingest into one wing can match against:
// that wing's sources (wiki/<wing>/sources/) and the SHARED concepts and
// entities (wiki/concepts/, wiki/entities/), which belong to no single wing
// because their slug is the graph's global identity. Other wings' sources are
// not read.
func LoadWingInventory(brainDir, wing string) (map[PageType][]Entry, error) {
result, err := LoadInventory(brainDir)
if err != nil {
return nil, err
}
result[PageTypeSource] = []Entry{} // the retired source layout is not this wing's
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
dir := "wiki/" + wing + "/sources"
names, err := bfs.ReadDirRegular(dir)
if errors.Is(err, fs.ErrNotExist) {
return result, nil
}
if err != nil {
return nil, fmt.Errorf("read dir %s: %w", dir, err)
}
for _, name := range names {
if !strings.HasSuffix(name, ".md") {
continue
}
rel := dir + "/" + name
slug := strings.TrimSuffix(name, ".md")
title, aliases := readFrontmatter(bfs, rel, slug)
result[PageTypeSource] = append(result[PageTypeSource], Entry{Slug: slug, Title: title, Aliases: aliases, Type: PageTypeSource, Path: rel})
}
return result, nil
}
// FindSlugs returns the brain-relative path of EVERY existing page whose
// basename is slug, searching the retired dirs (wiki/{sources,concepts,
// entities}/) and every wing hall (wiki/<wing>/<hall>/), sorted. The slug is a
// page's graph identity, so two pages must not share one; returning all of
// them (not the first) is what lets a caller tell "only my own page" from "my
// own page and somebody else's".
func FindSlugs(brainDir, slug string) ([]string, error) {
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
file := slug + ".md"
var found []string
check := func(dir string) error {
names, err := bfs.ReadDirRegular(dir)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
for _, n := range names {
if n == file {
found = append(found, dir+"/"+file)
}
}
return nil
}
for _, d := range []string{"wiki/sources", "wiki/concepts", "wiki/entities"} {
if err := check(d); err != nil {
return nil, err
}
}
wings, err := bfs.ReadDirDirs("wiki")
if errors.Is(err, fs.ErrNotExist) {
return found, nil
}
if err != nil {
return nil, err
}
for _, w := range wings {
if w == "sources" || w == "concepts" || w == "entities" {
continue
}
halls, err := bfs.ReadDirDirs("wiki/" + w)
if err != nil {
continue
}
for _, h := range halls {
if err := check("wiki/" + w + "/" + h); err != nil {
return nil, err
}
}
}
sort.Strings(found)
return found, nil
}
+7 -8
View File
@@ -3,8 +3,7 @@ package wiki
import ( import (
"fmt" "fmt"
"os" "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"path/filepath"
"strings" "strings"
) )
@@ -27,14 +26,14 @@ func AppendLog(brainDir, source string, pages, warnings []string, date string) e
} }
sb.WriteString("\n") sb.WriteString("\n")
logPath := filepath.Join(brainDir, "log.md") // Appended through the brain root, never through a symlink (#158).
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) bfs, err := brainfs.Open(brainDir)
if err != nil { if err != nil {
return fmt.Errorf("open log: %w", err) return fmt.Errorf("open brain: %w", err)
} }
if _, err = f.WriteString(sb.String()); err != nil { defer func() { _ = bfs.Close() }()
f.Close() //nolint:errcheck if err := bfs.AppendRegular("log.md", []byte(sb.String())); err != nil {
return fmt.Errorf("write log: %w", err) return fmt.Errorf("write log: %w", err)
} }
return f.Close() return nil
} }
+79
View File
@@ -0,0 +1,79 @@
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() })
}
+1
View File
@@ -22,4 +22,5 @@ type Entry struct {
Title string Title string
Aliases []string Aliases []string
Type PageType Type PageType
Path string // brain-relative path; set for wing-layout entries
} }
@@ -0,0 +1,35 @@
package wiki
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#158: RebuildIndex wrote wiki/index.md and AppendLog appended
// log.md with os.WriteFile / os.OpenFile, so a symlinked file was written
// through.
func TestRebuildIndexAndAppendLogDoNotWriteThroughSymlinks(t *testing.T) {
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "concepts"), 0o755))
victim := filepath.Join(b, "raw", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "raw", "v.md"), filepath.Join(b, "wiki", "index.md")))
require.NoError(t, os.Symlink(filepath.Join("raw", "v.md"), filepath.Join(b, "log.md")))
assert.Error(t, RebuildIndex(b, "2026-10-05"))
assert.Error(t, AppendLog(b, "src", []string{"p"}, nil, "2026-10-05"))
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched", string(got))
require.NoError(t, os.Remove(filepath.Join(b, "wiki", "index.md")))
require.NoError(t, os.Remove(filepath.Join(b, "log.md")))
require.NoError(t, RebuildIndex(b, "2026-10-05"), "positive control")
require.NoError(t, AppendLog(b, "src", []string{"p"}, nil, "2026-10-05"), "positive control")
l, _ := os.ReadFile(filepath.Join(b, "log.md"))
assert.Contains(t, string(l), "src")
}
+16
View File
@@ -0,0 +1,16 @@
package wing_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func writePage(t *testing.T, dir, rel, body string) {
t.Helper()
full := filepath.Join(dir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
}
+46
View File
@@ -0,0 +1,46 @@
package wing
import (
"fmt"
"os"
"path/filepath"
"sort"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
)
// legacyDirs are the retired wiki/{concepts,entities,sources}/ layout, not wings.
var legacyDirs = map[string]bool{"concepts": true, "entities": true, "sources": true}
// KnownWings lists wiki/<wing>/ directories that contain at least one hall
// subdirectory, sorted. The retired layout is not a wing. A missing wiki/ is
// an error: an unreadable brain must not look like "no wings".
func KnownWings(brainDir string) ([]string, error) {
entries, err := os.ReadDir(filepath.Join(brainDir, "wiki"))
if err != nil {
return nil, fmt.Errorf("list wings: %w", err)
}
var out []string
for _, e := range entries {
if !e.IsDir() || legacyDirs[e.Name()] || !plainWingName(e.Name()) {
continue
}
subs, err := os.ReadDir(filepath.Join(brainDir, "wiki", e.Name()))
if err != nil {
continue
}
for _, s := range subs {
if s.IsDir() {
out = append(out, e.Name())
break
}
}
}
sort.Strings(out)
return out, nil
}
// plainWingName reports whether name is already in the canonical lower-case form
// the pipeline writes. Anything else would make the pipeline create a different
// wing than the one matched.
func plainWingName(name string) bool { return brain.Sanitise(name) == name }
+42
View File
@@ -0,0 +1,42 @@
package wing_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
)
func TestKnownWings_ListsWingsWithHallsAndSkipsTheRetiredLayout(t *testing.T) {
dir := t.TempDir()
writePage(t, dir, "wiki/homelab/facts/a.md", "x")
writePage(t, dir, "wiki/tapir/decisions/b.md", "x")
writePage(t, dir, "wiki/concepts/c.md", "x") // retired layout, a file directly under a legacy dir
writePage(t, dir, "wiki/sources/d.md", "x") // retired layout
writePage(t, dir, "wiki/entities/e.md", "x") // retired layout
writePage(t, dir, "wiki/loose-note.md", "x") // a file, not a wing
writePage(t, dir, "wiki/emptywing/_index.md", "x") // a dir with no hall subdirectory
got, err := wing.KnownWings(dir)
require.NoError(t, err)
assert.Equal(t, []string{"homelab", "tapir"}, got)
}
func TestKnownWings_NoWikiDirIsAnError(t *testing.T) {
_, err := wing.KnownWings(t.TempDir())
assert.Error(t, err, "a brain without wiki/ must not look like a brain with no wings")
}
// Known matches case-insensitively and the pipeline writes the lower-case name,
// so a wiki directory that is not already plain lower-case would make the
// pipeline create a different, new wing. It is not a candidate.
func TestKnownWings_SkipsADirectoryThatIsNotAPlainWingName(t *testing.T) {
dir := t.TempDir()
writePage(t, dir, "wiki/Radar/facts/a.md", "x")
writePage(t, dir, "wiki/has space/facts/a.md", "x")
writePage(t, dir, "wiki/ok/facts/a.md", "x")
got, err := wing.KnownWings(dir)
require.NoError(t, err)
assert.Equal(t, []string{"ok"}, got)
}
+53
View File
@@ -0,0 +1,53 @@
// Package wing decides which brain wing an incoming raw note belongs to
// without the human typing it (hyperguild#205, #208).
//
// An earlier design classified each note with a local model and cross-checked
// it with a text score. Measured against pages whose wing a human had chosen,
// the model was right 60% of the time with a saturated self-reported
// confidence, and the cross-check was right 11% of the time, so the gate it
// formed placed 2 of 55 pages (hyperguild#208). The Router that replaced it
// decides with rules only: a deliberate folder or frontmatter wing, else one
// default wing; nothing it cannot decide safely is guessed.
package wing
import (
"context"
"strings"
)
// Source says how a Decision was reached.
type Source string
const (
Explicit Source = "explicit" // subfolder or frontmatter named the wing
Default Source = "default" // no signal; the configured default wing
Exception Source = "exception" // needs a human
)
// Note is what the Placer sees of an incoming raw note.
type Note struct {
Dir string // first subfolder under raw/, "" when dropped at the top
FrontmatterWing string // `wing:` from the note's frontmatter, "" if absent
Text string
}
// Decision is the outcome. For an Exception, Wing holds the proposal when
// there was one (so a reviewer can approve it) and is empty otherwise.
type Decision struct {
Wing string
Source Source
Confidence float64
Reason string
}
// Placer decides where a raw note goes. Router is the production
// implementation; the interface lets the watcher stay ignorant of how.
type Placer interface {
Resolve(ctx context.Context, n Note) Decision
}
func exception(w string, conf float64, reason string) Decision {
return Decision{Wing: w, Source: Exception, Confidence: conf, Reason: reason}
}
func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
+248
View File
@@ -0,0 +1,248 @@
package wing
import (
"context"
"fmt"
"regexp"
"slices"
"strings"
"unicode"
xnorm "golang.org/x/text/unicode/norm"
)
// Router places a note with rules only, no model (hyperguild#208):
//
// 1. A subfolder or frontmatter `wing:` is the owner's deliberate choice. It
// must name a plain, EXISTING wing (a typo never creates one) and then it
// places, whatever the wing is: extraction runs on the local model, and it
// is the owner who dropped the note there. Two signals that disagree park.
// 2. With no signal, a note whose text matches ClientBlock is parked; any
// other note goes to Default, the one wing that is created on first use.
//
// Wings are read from disk on every decision, so a wing created while the
// server runs is usable at once.
type Router struct {
BrainDir string
Default string
ClientBlock *regexp.Regexp // nil = no content check
// FilterChunk is how many bytes of a note the content filter folds and
// matches at a time (0 = 1 MiB). Folding allocates several times its input,
// so a 64 MiB note is checked in pieces (with an overlap, so a marker on a
// boundary is still found) instead of all at once.
FilterChunk int
}
// Resolve implements Placer. It never returns an error: anything it cannot
// decide safely is an Exception with the reason spelled out.
func (r Router) Resolve(_ context.Context, n Note) Decision {
for _, raw := range []string{n.Dir, n.FrontmatterWing} {
if !isASCII(raw) {
// strings.ToLower folds some non-ASCII runes to ASCII (the Kelvin
// sign becomes "k"), which would let a lookalike name a real wing.
return exception("", 0, fmt.Sprintf("wing name %q contains non-ASCII characters", strings.TrimSpace(raw)))
}
}
dir, fm := norm(n.Dir), norm(n.FrontmatterWing)
switch {
case dir != "" && fm != "" && dir != fm:
return exception("", 0, fmt.Sprintf("subfolder %q and frontmatter wing %q disagree", dir, fm))
case dir != "":
return r.explicit(dir, "subfolder")
case fm != "":
return r.explicit(fm, "frontmatter")
}
if r.ClientBlock != nil && matchesClient(r.ClientBlock, n.Text, r.FilterChunk) {
return exception("", 0, "text matches the client-content marker; not defaulted into a shared wing")
}
if r.Default == "" || !usableWingName(r.Default) {
return exception("", 0, "no valid default wing configured")
}
return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"}
}
func (r Router) explicit(w, via string) Decision {
if !usableWingName(w) {
return exception("", 0, fmt.Sprintf("%s names %q, which is not a usable wing name", via, w))
}
if w == r.Default {
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
}
known, err := KnownWings(r.BrainDir)
if err != nil {
return exception(w, 0, fmt.Sprintf("cannot list wings: %v", err))
}
if !slices.Contains(known, w) {
return exception(w, 0, fmt.Sprintf("%s names %q: not an existing wing (new wings are never created from a folder name)", via, strings.TrimSpace(w)))
}
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
}
// NewRouter builds the production Router. defaultWing must be a plain wing
// name; clientPattern, when set, is a case-insensitive regexp over note text
// whose match parks an unsignalled note instead of filing it. Both fail at
// startup rather than later: a client filter that silently does nothing is
// worse than none.
func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
if defaultWing == "" || !usableWingName(defaultWing) {
return Router{}, fmt.Errorf("default wing %q is not a usable wing name", defaultWing)
}
r := Router{BrainDir: brainDir, Default: defaultWing}
if clientPattern != "" {
re, err := regexp.Compile("(?i)" + clientPattern)
if err != nil {
return Router{}, fmt.Errorf("client-content pattern: %w", err)
}
if re.MatchString("x") || re.MatchString("") {
return Router{}, fmt.Errorf("client-content pattern %q matches text that names no client; it would park every note", clientPattern)
}
r.ClientBlock = re
}
return r, nil
}
// reservedWingNames are directory names under wiki/ that are not wings: the
// retired layout and the root. A note must never be proposed into one.
var reservedWingNames = map[string]bool{"sources": true, "concepts": true, "entities": true, "wiki": true}
// usableWingName: a plain name that is not reserved.
func usableWingName(w string) bool { return plainWingName(w) && !reservedWingNames[w] }
func isASCII(s string) bool {
for _, r := range s {
if r > unicode.MaxASCII {
return false
}
}
return true
}
const (
defaultFilterChunk = 1 << 20
filterOverlap = 4096
)
// matchesClient applies the client-content pattern to the text in bounded
// chunks, each overlapping the next, so memory stays proportional to the chunk
// and a marker on a chunk edge is still found.
func matchesClient(re *regexp.Regexp, text string, chunk int) bool {
if chunk <= 0 {
chunk = defaultFilterChunk
}
overlap := filterOverlap
if overlap >= chunk {
overlap = chunk / 2
}
for start := 0; start < len(text); {
end := start + chunk
if end >= len(text) {
end = len(text)
}
if matchesFolded(re, text[start:end]) {
return true
}
if end == len(text) {
return false
}
// A cut inside a rune is harmless: the overlap carries every match that
// spans at most filterOverlap bytes whole into the next chunk. A match
// longer than that (a pattern like `acme.{5000}corp`, or padding
// between two words) can be missed on a chunk edge; documented limit.
start = end - overlap
}
return false
}
// matchesFolded matches the pattern against the chunk as it is and against
// folded forms, so the cheap evasions do not defeat it. Folding: NFKD (fullwidth
// letters, accents become base letter + mark), then marks, format characters and
// invisible fillers dropped, Cyrillic/Greek lookalikes mapped to Latin, every
// dash made "-". Views: whitespace collapsed to one space (so "acme corp" and
// \s work), whitespace removed (so "acme-\nclient" works), and the same with a
// soft hyphen read as a dash. It catches what it names and nothing else:
// leetspeak, punctuation splits (a.c.m.e), encodings and the many homoglyphs
// not in the small table below are not caught. It is a filter, not a classifier.
func matchesFolded(re *regexp.Regexp, chunk string) bool {
if re.MatchString(chunk) {
return true
}
decomposed := xnorm.NFKD.String(chunk)
return re.MatchString(fold(decomposed, false, true)) ||
re.MatchString(fold(decomposed, false, false)) ||
re.MatchString(fold(decomposed, true, false))
}
// fold builds one view. softAsDash reads U+00AD as "-" instead of dropping it;
// keepSpace collapses whitespace to a single space instead of removing it.
func fold(s string, softAsDash, keepSpace bool) string {
var b strings.Builder
b.Grow(len(s))
space := false
for _, r := range s {
switch {
case r == '\u00ad' && softAsDash:
b.WriteRune('-')
space = false
continue
case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Mc, r) ||
unicode.Is(unicode.Cf, r) || invisibleFiller[r]:
continue
case unicode.IsSpace(r) || unicode.Is(unicode.Zs, r):
if keepSpace && !space {
b.WriteByte(' ')
space = true
}
continue
}
space = false
switch {
case unicode.Is(unicode.Pd, r) || r == '\u2212':
b.WriteRune('-')
default:
if l, ok := latinLookalike[r]; ok {
r = l
}
b.WriteRune(r)
}
}
return b.String()
}
// invisibleFiller are characters that render as nothing but are not format
// characters: Hangul fillers and the blank Braille pattern.
var invisibleFiller = map[rune]bool{0x115F: true, 0x1160: true, 0x3164: true, 0xFFA0: true, 0x2800: true}
// latinLookalike maps the commonest Cyrillic and Greek letters that read as
// Latin ones. Deliberately small; see matchesFolded.
var latinLookalike = map[rune]rune{
'\u0430': 'a', '\u0435': 'e', '\u043e': 'o', '\u0440': 'p', '\u0441': 'c', '\u0445': 'x', '\u0443': 'y',
'\u0456': 'i', '\u0458': 'j', '\u0455': 's', '\u04bb': 'h', '\u0501': 'd', '\u043a': 'k', '\u043d': 'h', '\u0442': 't',
'\u0410': 'A', '\u0412': 'B', '\u0415': 'E', '\u041a': 'K', '\u041c': 'M', '\u041d': 'H', '\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0422': 'T', '\u0425': 'X',
'\u03bf': 'o', '\u03bd': 'v', '\u03c1': 'p', '\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u039f': 'O', '\u03a1': 'P',
}
// WrapNames turns a bare list of client names (a regexp alternation such as
// "acme|globex") into a pattern that matches a name only as a whole word, with
// word edges decided by Unicode letters and digits: Go's \b is ASCII-only, so
// with it a name that begins or ends in a letter like Å, é or Ø would never
// match. An underscore or punctuation is a boundary (trucker_ecommerce matches
// "trucker"), a letter or digit glued on is not (acmecorp, acme2).
//
// The list is validated first, on its own: it must compile as a regexp, must not
// match the empty string (an empty alternative such as "a||b" would match
// everywhere), and must be self-contained: a value like "x)|.*(" compiles alone
// only if it is balanced, so it cannot break out of the group it is put in.
func WrapNames(names string) (string, error) {
if strings.TrimSpace(names) == "" {
return "", fmt.Errorf("client names list is empty")
}
re, err := regexp.Compile("(?i)" + names)
if err != nil {
return "", fmt.Errorf("client names are not a valid regexp alternation: %w", err)
}
if re.MatchString("") {
return "", fmt.Errorf("client names contain an empty alternative; it would match everywhere")
}
return `(?:^|[^\p{L}\p{N}])(?:` + names + `)(?:[^\p{L}\p{N}]|$)`, nil
}
+292
View File
@@ -0,0 +1,292 @@
package wing_test
import (
"context"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
)
func routerBrain(t *testing.T) string {
dir := t.TempDir()
writePage(t, dir, "wiki/homelab/facts/a.md", "x")
writePage(t, dir, "wiki/rikatillsammans/facts/b.md", "x")
return dir
}
func TestRouter_Decisions(t *testing.T) {
tests := []struct {
name string
note wing.Note
want wing.Source
wantWing string
}{
{"no signal goes to the default wing", wing.Note{Text: "a talk about agents"}, wing.Default, "web-clip"},
{"a subfolder naming an existing wing places there", wing.Note{Dir: "homelab", Text: "x"}, wing.Explicit, "homelab"},
{"a deliberate subfolder into a client wing is the owner's call and is placed", wing.Note{Dir: "rikatillsammans", Text: "x"}, wing.Explicit, "rikatillsammans"},
{"frontmatter wing places like a subfolder", wing.Note{FrontmatterWing: "homelab", Text: "x"}, wing.Explicit, "homelab"},
{"subfolder is case-insensitive", wing.Note{Dir: "HomeLab", Text: "x"}, wing.Explicit, "homelab"},
{"a typo never creates a wing", wing.Note{Dir: "hoemlab", Text: "x"}, wing.Exception, "hoemlab"},
{"conflicting signals are parked", wing.Note{Dir: "homelab", FrontmatterWing: "rikatillsammans", Text: "x"}, wing.Exception, ""},
{"the default wing may be named explicitly even before it exists", wing.Note{Dir: "web-clip", Text: "x"}, wing.Explicit, "web-clip"},
{"client marker with no signal is parked, not defaulted", wing.Note{Text: "notes from the ACME-CLIENT kickoff"}, wing.Exception, ""},
{"client marker does not block a deliberate subfolder", wing.Note{Dir: "homelab", Text: "ACME-CLIENT"}, wing.Explicit, "homelab"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := wing.Router{BrainDir: routerBrain(t), Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
d := r.Resolve(context.Background(), tt.note)
assert.Equal(t, tt.want, d.Source)
assert.Equal(t, tt.wantWing, d.Wing)
assert.NotEmpty(t, d.Reason)
})
}
}
func TestRouter_ReadsTheWingsFreshOnEveryDecision(t *testing.T) {
dir := routerBrain(t)
r := wing.Router{BrainDir: dir, Default: "web-clip"}
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Dir: "newwing", Text: "x"}).Source)
writePage(t, dir, "wiki/newwing/facts/c.md", "x") // a wing created after startup
assert.Equal(t, wing.Explicit, r.Resolve(context.Background(), wing.Note{Dir: "newwing", Text: "x"}).Source,
"a wing made while the server runs is usable without a restart")
}
func TestRouter_AnUnreadableBrainParksInsteadOfGuessing(t *testing.T) {
r := wing.Router{BrainDir: t.TempDir() + "/missing", Default: "web-clip"}
d := r.Resolve(context.Background(), wing.Note{Dir: "homelab", Text: "x"})
assert.Equal(t, wing.Exception, d.Source)
}
func TestRouter_WithoutADefaultAnUnsignalledNoteIsParked(t *testing.T) {
r := wing.Router{BrainDir: routerBrain(t)}
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: "x"}).Source)
}
func TestRouter_ADefaultThatIsNotAPlainNameIsRefused(t *testing.T) {
r := wing.Router{BrainDir: routerBrain(t), Default: "../escape"}
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: "x"}).Source)
}
func TestRouter_ANameThatIsNotPlainIsRefusedAsSuch(t *testing.T) {
for _, bad := range []string{"../x", "a/b", "has space", "dot.ted"} {
t.Run(bad, func(t *testing.T) {
// Default equals the bad name: the "named explicitly" shortcut for the
// default wing must not wave a malformed name through.
r := wing.Router{BrainDir: routerBrain(t), Default: bad}
d := r.Resolve(context.Background(), wing.Note{Dir: bad, Text: "x"})
assert.Equal(t, wing.Exception, d.Source)
assert.Empty(t, d.Wing, "a malformed name is never echoed as a proposal")
assert.Contains(t, d.Reason, "not a usable wing name")
})
}
}
func TestNewRouter(t *testing.T) {
dir := routerBrain(t)
t.Run("valid", func(t *testing.T) {
r, err := wing.NewRouter(dir, "web-clip", "acme-client|globex")
assert.NoError(t, err)
d := r.Resolve(context.Background(), wing.Note{Text: "kickoff with GLOBEX"})
assert.Equal(t, wing.Exception, d.Source, "the marker is matched case-insensitively")
})
t.Run("no marker pattern means no content check", func(t *testing.T) {
r, err := wing.NewRouter(dir, "web-clip", "")
assert.NoError(t, err)
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: "anything"}).Source)
})
t.Run("a default that is not a plain wing name is a startup error", func(t *testing.T) {
_, err := wing.NewRouter(dir, "Web Clip", "")
assert.Error(t, err)
})
t.Run("an empty default is a startup error", func(t *testing.T) {
_, err := wing.NewRouter(dir, "", "")
assert.Error(t, err)
})
t.Run("a pattern that does not compile is a startup error", func(t *testing.T) {
_, err := wing.NewRouter(dir, "web-clip", "([unclosed")
assert.Error(t, err, "a client filter that silently does nothing is worse than none")
})
}
// From the review of #211: the content filter is the only guard on the default
// lane, so it must survive the cheap evasions.
func TestRouter_ClientMarkerSurvivesCheapEvasions(t *testing.T) {
r := wing.Router{BrainDir: routerBrain(t), Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
tests := []struct{ name, text string }{
{"non-breaking hyphen", "notes on acme‑client"},
{"zero-width joiner inside", "notes on acme\u200d-client"},
{"zero-width space inside", "notes on ac\u200bme-client"},
{"split across lines", "notes on acme-\nclient"},
{"spaced hyphen", "notes on acme - client"},
{"fullwidth letters", "notes on acme-client"},
{"soft hyphen", "notes on acme\u00ad-client"},
{"en dash", "notes on acme–client"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := r.Resolve(context.Background(), wing.Note{Text: tt.text})
assert.Equal(t, wing.Exception, d.Source, tt.text)
})
}
t.Run("an unrelated text still files", func(t *testing.T) {
d := r.Resolve(context.Background(), wing.Note{Text: "a talk about agents and a client-server model"})
assert.Equal(t, wing.Default, d.Source)
})
}
func TestRouter_FolderNamesAreAsciiAndNotReserved(t *testing.T) {
dir := routerBrain(t)
writePage(t, dir, "wiki/kubernetes/facts/a.md", "x")
r := wing.Router{BrainDir: dir, Default: "web-clip"}
tests := []struct {
name string
dir string
}{
{"Kelvin sign lowercases to an ASCII k", "Kubernetes"},
{"cyrillic lookalike", "h\u043emelab"},
{"retired layout name: sources", "sources"},
{"retired layout name: concepts", "concepts"},
{"retired layout name: entities", "entities"},
{"wiki", "wiki"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := r.Resolve(context.Background(), wing.Note{Dir: tt.dir, Text: "x"})
assert.Equal(t, wing.Exception, d.Source)
assert.Empty(t, d.Wing, "never proposed as a wing")
})
}
t.Run("plain ASCII case folding still works", func(t *testing.T) {
d := r.Resolve(context.Background(), wing.Note{Dir: "KubernetEs", Text: "x"})
assert.Equal(t, wing.Explicit, d.Source)
assert.Equal(t, "kubernetes", d.Wing)
})
}
func TestNewRouter_AReservedDefaultIsRefused(t *testing.T) {
_, err := wing.NewRouter(routerBrain(t), "sources", "")
assert.Error(t, err)
}
// From the second review of #211: the multi-word, \s and \b patterns an operator
// would actually write, and the next tier of evasions.
func TestRouter_ClientMarkerHandlesRealisticPatternsAndHarderEvasions(t *testing.T) {
tests := []struct {
name, pattern, text string
}{
{"space in pattern vs NBSP", `acme corp`, "about acme\u00a0corp today"},
{"space in pattern vs two spaces", `acme corp`, "about acme corp today"},
{"space in pattern vs newline", `acme corp`, "about acme\ncorp today"},
{"backslash-s pattern vs NBSP", `acme\s+corp`, "about acme\u00a0corp today"},
{"word boundary vs a zero-width char inside", `\bacme\b`, "the ac\u200bme corp"},
{"dash pattern vs a soft hyphen as the dash", `acme-client`, "acme\u00adclient"},
{"cyrillic a", `acme-client`, "\u0430cme-client"},
{"cyrillic e", `acme-client`, "acm\u0435-client"},
{"cyrillic o and c", `coop`, "c\u043e\u043ep"},
{"combining acute", `acme-client`, "a\u0301cme-client"},
{"precomposed accent", `acme-client`, "\u00e1cme-client"},
{"hangul filler", `acme-client`, "ac\u3164me-client"},
{"halfwidth hangul filler", `acme-client`, "ac\uffa0me-client"},
{"braille blank", `acme-client`, "ac\u2800me-client"},
{"grapheme joiner", `acme-client`, "ac\u034fme-client"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, err := wing.NewRouter(routerBrain(t), "web-clip", tt.pattern)
assert.NoError(t, err)
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: tt.text}).Source, "%q should be caught by %q", tt.text, tt.pattern)
})
}
t.Run("ordinary text is not caught by a multi-word pattern", func(t *testing.T) {
r, _ := wing.NewRouter(routerBrain(t), "web-clip", `acme corp`)
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: "acme is not corp, and corp is not acme"}).Source)
})
}
// The folding allocates several times the text, so a large note is checked in
// bounded chunks. A marker that straddles a chunk edge must still be found.
func TestRouter_ClientMarkerIsFoundAcrossAChunkBoundary(t *testing.T) {
r, err := wing.NewRouter(routerBrain(t), "web-clip", `acme-client`)
assert.NoError(t, err)
r.FilterChunk = 64
for _, at := range []int{0, 30, 55, 58, 62, 64, 70, 120, 190} {
text := strings.Repeat("x", at) + "acme-client" + strings.Repeat("y", 200)
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: text}).Source, "marker at offset %d was missed", at)
}
clean := strings.Repeat("lorem ipsum ", 100)
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source)
}
func TestRouter_ClientMarkerChunkingDoesNotSplitAMultiByteRune(t *testing.T) {
r, err := wing.NewRouter(routerBrain(t), "web-clip", `acme-client`)
assert.NoError(t, err)
r.FilterChunk = 65 // odd, over two-byte runes: every boundary falls inside a rune unless it is moved
for pad := 0; pad < 8; pad++ {
text := strings.Repeat("\u00e9", 40+pad) + "acme-client" + strings.Repeat("\u00e9", 40)
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: text}).Source, "pad %d", pad)
}
clean := strings.Repeat("\u00e9", 300)
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source, "no panic or false match on a text of 2-byte runes")
}
// From the review of #214: the shared secret is a bare list of client names.
func TestWrapNames(t *testing.T) {
park := func(t *testing.T, names, text string) bool {
t.Helper()
pat, err := wing.WrapNames(names)
assert.NoError(t, err)
r, err := wing.NewRouter(routerBrain(t), "web-clip", pat)
assert.NoError(t, err)
return r.Resolve(context.Background(), wing.Note{Text: text}).Source == wing.Exception
}
t.Run("a short name does not match inside ordinary words", func(t *testing.T) {
assert.False(t, park(t, "dif|trucker", "a different approach to modifying prompts"))
assert.True(t, park(t, "dif|trucker", "kickoff for the DIF project"))
})
t.Run("names with a non-ASCII letter at the edge still match (Go's \\b would never)", func(t *testing.T) {
assert.True(t, park(t, "Åke|Société|Ørsted", "Åke called"))
assert.True(t, park(t, "Åke|Société|Ørsted", "la Société wrote"))
assert.True(t, park(t, "Åke|Société|Ørsted", "Ørsted wind"))
})
t.Run("identifiers with underscores and punctuation count as boundaries", func(t *testing.T) {
assert.True(t, park(t, "trucker", "repo trucker_ecommerce was cloned"))
assert.True(t, park(t, "trucker", "(Trucker)"))
})
t.Run("a name inside a longer word or with digits glued on is not a boundary", func(t *testing.T) {
assert.False(t, park(t, "acme", "acmecorp"))
assert.False(t, park(t, "acme", "acme2"))
})
}
func TestWrapNames_RefusesWhatWouldBreakOrBlockEverything(t *testing.T) {
for _, bad := range []string{
"x)|.*(", // breaks out of the group
"acme)|(b", // changes the structure
"a||b", // empty alternative matches everywhere
"acme|", // trailing empty alternative
"|acme", // leading empty alternative
"", // nothing
"c++ corp", // not a valid regexp
"([unclosed", // not a valid regexp
} {
t.Run(bad, func(t *testing.T) {
_, err := wing.WrapNames(bad)
assert.Error(t, err)
})
}
}
func TestNewRouter_RefusesAPatternThatMatchesTheEmptyString(t *testing.T) {
for _, p := range []string{`.*`, `x*`, `^`, `\b`, `(a|)`} {
t.Run(p, func(t *testing.T) {
_, err := wing.NewRouter(routerBrain(t), "web-clip", p)
assert.Error(t, err, "it would park every note")
})
}
}