Skip to content

Overnight/test harness 2026 04 20#10

Merged
claudenstein merged 93 commits into
mainfrom
overnight/test-harness-2026-04-20
Jul 20, 2026
Merged

Overnight/test harness 2026 04 20#10
claudenstein merged 93 commits into
mainfrom
overnight/test-harness-2026-04-20

Conversation

@claudenstein

Copy link
Copy Markdown
Owner

No description provided.

Frank Dosk and others added 30 commits May 6, 2026 10:43
CLAUDE.md states the global --no-index flag "prevents Bleve
from opening at all (and cascades to disable Layer D
publishing)" but only the first half held: the daemon skipped
indexer.Open while engine.startPublisher still launched the
BEP-44 keyword publisher under the user's identity. A user
running `swartznet add --no-index` was still announcing
keyword pointers to the DHT — a privacy regression that
contradicted the documented global opt-out.

Mirror the daemon-level NoIndex flag into Config.NoIndex,
gate startPublisher (and the sn_search Publisher capability
bit) on it alongside the existing DisableDHTPublish, and lock
the cascade in with a regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small follow-ups from the whole-codebase review:

  - dhtindex/dht.go — add nextSeq() helper to clamp BEP-44
    sequence numbers at math.MaxInt64. The seqToPut closure
    has no error return, so an int64 wrap to negative would
    silently violate BEP-44 monotonicity. Defensive only:
    unreachable in practice (≈146 trillion years at one
    put/sec).
  - CLAUDE.md / docs/05 — remove the empty internal/search
    reference and the "shared SearchResult struct" claim.
    Each layer carries its own response type
    (indexer.SearchResponse, swarmsearch.QueryResponse,
    dhtindex.LookupResponse) reconciled at the httpapi
    boundary. Doc was aspirational; code never had it.
  - cmd_create.go — document why the Create subcommand spins
    up engine.New directly instead of going through
    daemon.New (one-shot tool, no need for indexer /
    publisher / API subsystems).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related GUI-test cleanups from the whole-codebase review:

(1) skip_helpers_test.go introduces skipMissing(t, d, name)
    which logs the daemon's full wiring state (Eng / Index /
    CompPub / CompSub / API / Bootstrap) before t.Skipf. The
    bare `t.Skip("daemon did not wire up X")` pattern gave no
    signal about whether the failure was X-specific (real
    regression) or environmental (DHT unavailable, so
    everything is nil); the diagnostic form makes the
    difference obvious in `go test -v` output. Applied to
    every gui-test silent-skip site.

(2) Five "no-panic only" tests now assert observable side
    effects:
      - run_search_empty_test.go: searchBtn stays enabled,
        statusLbl stays empty, resultBox preserves preexisting
        content. Catches a regression that fell through the
        empty-query early-return.
      - selected_actions_test.go: each action recovers from
        any nil-dl.d panic, asserts dl.selected and dl.snaps
        stay unchanged.
      - pollloops_sync_test.go (status): asserts
        torrentsLabels[0] flips off the "-" placeholder after
        the initial refresh runs.
      - pollloops_sync_test.go (companion): asserts pollLoop
        exits within 500ms of ctx cancel — catches a dropped
        ctx.Done() arm that would otherwise hang to the next
        4s tick.
      - app_loops_tick_test.go + downloads_pollloop_tick_test.go:
        same 500ms-exit-budget assertion plus dl.snaps
        non-empty after the first tick (proves tick.C body
        actually ran).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion-index pointers used to carry just the infohash:

  {ih: <20-byte>}

A subscriber following a publisher that stops re-publishing
would silently keep chasing the last known infohash with no
signal that the publisher had gone offline. Add a publisher-
asserted Unix-seconds timestamp:

  {ih: <20-byte>, ts: <int64>}

so subscribers can detect stale pointers and apply policy
(warn, deprioritize, drop the follow). Wire-compat is
preserved by bencode's extension model — pre-ts publishers
emit `{ih: ...}` and decoders treat TS=0 as "unknown freshness,
accept"; pre-ts subscribers ignore the extra dict entry. No
coordinated upgrade required.

GetInfohashPointerInfo is the new freshness-aware accessor;
the legacy GetInfohashPointer is now a thin wrapper, so every
existing caller (companion subscriber, all test fakes) keeps
working without changes. Added pointer_ts_test.go with round-
trip, omitempty-on-zero, legacy-decode tolerance, and
forward-compat assertions to lock in the wire contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two extractor robustness improvements:

  - engine.New now passes a 100 MiB global per-file extract
    cap to NewPipeline instead of 0 (= "each extractor's own
    default"). Previously the archive extractor enforced 64
    MiB but PDF / EPUB / DOCX / FB2 silently buffered whatever
    the file claimed; a torrent of pathological PDFs could
    blow up resident memory before any per-extractor limit
    kicked in.
  - safeExtract arms a soft 60s watchdog that emits a
    `pipeline.extract_slow` warning when an extract exceeds
    its budget. Soft because Go can't terminate goroutines
    externally — the watchdog observes and reports, the
    worker keeps going. Real protection still comes from the
    size cap plus per-extractor io.LimitReader plus panic
    recovery; the watchdog surfaces hangs in logs before the
    pipeline channel backs up.

The Extractor interface still doesn't take a context.Context
— plumbing one through every extractor is a larger refactor
not justified by current threat modelling. Size cap +
watchdog addresses the practical concern (memory blowups,
silent hangs) without the churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A multi-package review produced 55 adversarially-verified findings
across 13 packages; all are fixed here, each with a regression test
(except pure-comment nits). No on-wire bytes changed and Layer L/S/D
isolation is preserved, so the mainline-compat matrix stays green.

Untrusted-input availability (remote DoS, all unauthenticated):
- engine: reject zero infohash from untrusted BEP-46 pointer before
  AddTorrentInfoHash (panicif.Zero crash); recover() on the add path
- indexer/extractors: subtitle honours maxBytes; MKV/readFull bound
  allocation from untrusted EBML VINT; ZIP/XML/PDF extractors cap
  decompressed output (zip-bomb); ID3 tagSize clamped
- companion: B-tree reader requires strictly-downward/increasing/
  in-range child indices + depth budget + visited-set + leaf cap
  (self/back-pointing/DAG pages no longer overflow the stack)
- swarmsearch: responder sync sessions bounded per peer + staleness
  reaper emitting sync_end aborted (unbounded-memory DoS)
- dhtindex/indexer: DHT decode + infohash->query paths size-bounded
  / structured (no QueryString interpolation)

Fail-closed determinism:
- dhtindex: publish requires >=1 confirmed node before MarkPublished;
  manifest size estimate reserves the timestamp width
- cli: explicit --key/--identity are load-only (no implicit mint);
  defaultIdentityPath honours XDG; --dht-insecure/--regtest gated;
  config.Validate fails closed on those flags outside tests
- daemon: companion publisher writes to CompanionDir (was DataDir);
  anchor-fetch goroutine cancel-tracked + joined on Close; HTTPS
  bootstrap rejects non-https URLs
- engine: restored paused torrent no longer flips files to Normal

Concurrency, correctness & identity:
- indexer: extract watchdog runs Extract against a hard deadline;
  deleteByQueryLocked bounded; Stats no longer truncates past 64k
  content docs; AllTorrentDocs preserves SignedBy
- swarmsearch: RIBLT symbol Index validated; ShareLocal==1 fails
  closed; StartFeeler idempotent; misbehavior scores charged;
  mergeResponses dedups per peer
- security: seed-list pubkeys normalised; corrupt Bloom (m==0)
  rejected; Bloom double-hash forces odd stride; loaded key public
  half re-derived from seed and verified
- gui: deterministic follow-row ordering + pubkey-keyed unfollow;
  file-priority Select no longer re-fires OnChanged on recycle; no
  false "Download complete" toasts on startup
- httpapi: CSRF/Origin/Host defense + non-loopback-bind guard on
  mutating endpoints; search timeouts clamped; status pubkey populated

104 files changed; 32 new regression tests. Build + full non-GUI and
GUI suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fixes)

The full `go test -race ./...` sweep had three independent failure
modes, all test-only — no production code is racy or buggy.

internal/gui (data races): under the Fyne *test* driver, fyne.Do runs
its callback inline on the spawning goroutine instead of serializing
onto the single UI thread (as the real GLFW driver does). Async UI
flows whose tests did not join the spawned goroutine therefore rendered
concurrently with other rendering against Fyne's unsynchronized
process-global font/SVG cache. Fixed by giving each test-exercised
async flow a nil-in-production seam invoked after its fyne.Do returns,
and having tests deterministically join on it (replacing time.Sleep
drains). Seams: afterCreateTorrent (create.go, prior commit),
afterRunSearch (search.go), afterRefreshPublisher (companion.go),
afterSetAllPriorities (files_dialog.go), afterRemoveSelected /
afterAddMagnet (downloads.go). Production logic, signatures, and
off-thread behavior are unchanged. Verified across 40+ full-suite
-race iterations with zero DATA RACE reports.

internal/testlab (timing flakes under load):
- TestLayerDPublisherRefreshKeepsItemFresh: widened the refresh-advance
  budget 12s->30s, and replaced the single-shot post-refresh DHT lookup
  with a polling retry (45s budget, 10s per attempt). A loopback DHT
  traversal can return zero responders under saturated -race load even
  when nothing is wrong; the assertion (post-refresh the keyword still
  resolves to our infohash) is unchanged.

cmd/swartznet (timing flakes under load): widened four "did this
near-instant event happen?" assertion timeouts — the two signalContext
cancellation tests (1s->5s) and the two progressLoop exit tests
(2s->5s). A genuinely hung loop still fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aware metadata waits

Fixes the engine-unit findings from the 2026-06-09 whole-codebase review:

- FetchCompanionTorrent rejects companion torrents whose info dict
  declares more than maxCompanionBytes (32 MiB) BEFORE requesting any
  pieces — the declared length behind a BEP-46 pointer is fully
  attacker-controlled and the only previous bound was the subscriber's
  wall-clock FetchTimeout (disk-fill). Fail closed with an error so
  the subscriber records the failure.
- FetchCompanionTorrent validates the untrusted info.Name as a plain
  file name (no separators / dot entries / empty) before joining it
  into DataDir; defence-in-depth on the hand-built returned path.
- Inbound sn_search reply writes are now gated on a bounded writer
  semaphore (gatedReplyWriter); a handler streaming many replies can
  no longer spawn unbounded write goroutines. On overload the reply
  is dropped with an error so the protocol layer sees the send fail.
- autoDownload / autoIndex / upgradeMagnetSession metadata waits now
  select on bgCtx.Done() so Close reclaims the goroutines promptly
  instead of leaking them for up to 5-10 minutes.
- restoreEntry rejects session TorrentFile values that are not plain
  file names, so a corrupted manifest cannot read .torrent files
  outside the torrents dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… nits

Review findings (CODE_REVIEW_2026-06-09, gui unit):

- downloads: the primary selection is now keyed by infohash
  (selectedKey) instead of a row index. pollLoop re-sorts snaps
  every 2 s, so the stored index could silently point at a
  different torrent by the time Remove/Pause/Files/Toggle-Index
  fired — Remove being destructive. The key is resolved against
  the live snaps and cleared when its torrent disappears.
- downloads+search: magnet links are built via magnetLink(),
  which URL-escapes the dn value (and omits it when empty).
  Names are remote/DHT-sourced, so an embedded '&' could inject
  magnet params — and search fed the result straight back into
  AddMagnetURI.
- search: flagHit no longer falls back to demoting EVERY known
  indexer when a hit has no source attribution (attacker-
  weaponizable reputation wipe; confirmHit never fanned out).
  It now no-ops with a user-visible "no reputations changed" note.
- search: the Limit entry is parsed strictly (strconv.Atoi,
  positive only) instead of lax Sscanf that let negatives and
  trailing garbage through.
- app: pollNotifications prunes lastNotified entries for torrents
  no longer present, bounding the map across remove/re-add churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three confirmed review findings in internal/httpapi:

- /flag no longer demotes every known indexer when the infohash has
  no recorded source attribution. The old fallback was attacker-
  weaponizable (seed an unattributed spam hit, get it flagged, crater
  the whole reputation table including trusted publishers). Now the
  endpoint fails closed: 200 OK, but zero pubkeys demoted, with the
  new additive FlagResponse.IndexersFlagged field reporting how many
  indexers were actually penalized. TestHTTPFlagFallbackNoSources
  updated to lock the fail-closed behavior.

- file-index path segment in POST /torrents/{ih}/files/{index}/priority
  parsed with strconv.Atoi instead of fmt.Sscanf("%d"), which silently
  accepted trailing garbage ("3abc" parsed as 3).

- /search query string capped at 1024 bytes (maxSearchQueryBytes);
  oversized queries are rejected with 400 before reaching Bleve or
  any swarm/DHT fan-out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ile index

Three confirmed findings from the 2026-06-09 review:

- create --seed used to seed with DHT disabled and an OS-assigned port,
  so a trackerless seed printed "Seeding..." while being unreachable.
  Engine config now lives in createEngineConfig: hash-only runs keep
  the minimal no-DHT/no-upload shape; --seed keeps DHT and uploads on.
  ListenPort stays 0 in both modes — the DHT announce carries the real
  bound port, and an ephemeral port avoids clashing with a daemon
  already on the default port.
- create --identity without --sign was silently ignored (unsigned
  torrent while the user believed their key was used); now exitUsage
  with a hint.
- files set-priority interpolated the raw index argument into the URL
  path; it is now validated as a plain non-negative integer and sent
  in canonical decimal form.

Regression tests: TestCreateEngineConfig, TestCmdCreateIdentityWithoutSign,
TestCmdFilesSetPriorityBadIndex, TestCmdFilesSetPriorityIndexCanonicalized.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
readBloom validated only the upper bound of the declared bitsLen, so a
truncated/corrupt/tampered bloom file declaring a large m with a small
bitset loaded fine and then panicked out-of-bounds on the first Add or
Test — turning a recoverable load error into a daemon-wide crash.

Require an exact match bitsLen == (m+63)/64 (the writer always emits
exactly that), and reject degenerate m==0 / k==0 headers that would
divide-by-zero or match-everything later in indices().

Regression test hand-crafts undersized/oversized/zero-parameter files
and asserts LoadOrCreateBloom errors at parse time instead of panicking
later; a round-trip test pins that writer-produced files still load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it cap

Three confirmed nits from the 2026-06-09 whole-codebase review:

- daemon.New's channel-A goroutine snapshotted the anchor set once
  and was never spawned when the set was empty (the dev default), so
  anchors added later via FallbackToHTTPS were never fetched. Replace
  the one-shot goroutine with Bootstrap.runAnchorLoop: an initial
  pass over configured anchors plus a re-run on a coalesced signal
  that FallbackToHTTPS raises whenever it extends the anchor set.
  Exits on ctx cancellation; RunAnchors stays idempotent.

- LoadFollowFile decoded the follow file with no size bound. Reads
  are now capped at 1 MiB via io.LimitReader and an oversized file
  is rejected wholesale (fail closed) instead of half-loading.

- Bootstrap.admit counted anchors against MaxTrackedPublishers and
  refused admissions silently. Anchors (small, project-controlled
  set) are now exempt from the cap so they can neither be starved
  nor shrink the candidate budget, and cap refusals log a
  daemon.aggregate_bootstrap.admit_capped warning so a starved node
  no longer looks identical to a quiet network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…asked-peer gate

Three confirmed findings from the 2026-06-09 review:

- sync_session: the per-session max_bytes budget (SPEC §2.3/§2.9) was
  declared and echoed on the wire but never incremented or compared,
  and ApplyRecords had no phase guard — a capability-gated peer could
  stream unlimited sync_records frames, each forcing up to 500
  synchronous ed25519 verifications on the read-loop goroutine.
  ApplyRecords now requires PhaseNeeded/PhaseFulfilled, accounts
  every frame's record+missing bytes into bytesIn, tracks a
  cumulative record count (echoed as sync_end 'decoded'), and
  returns ErrSyncBytesBudgetExceeded once over budget.
  BuildRecordsFrame symmetrically accounts bytesOut. No new wire
  fields — this implements the already-documented limit_exceeded
  path.

- handler: onSyncSymbols and onSyncRecords no longer log violations
  at Debug and limp along. Any ApplySymbols/ApplyRecords error now
  fails closed with the package's existing teardown pattern:
  terminal sync_end (limit_exceeded for budget overruns, aborted
  otherwise), releaseSyncSession, chargeMisbehavior. Both handlers
  now take the reply func threaded through handleSyncFrame.

- query: routeResult/routeReject accepted frames for a live txid
  from ANY peer (txids are a guessable monotonic counter). Each
  pendingQuery now carries the immutable asked-peer set, populated
  before registration; frames from unasked peers are dropped and
  charged.

Regression tests: sync_records_budget_test.go (phase guard, byte
budget incl. missing-ID accounting, handler fail-closed teardown for
records and symbols) and route_unasked_peer_test.go (spoofed Result/
Reject dropped + charged, asked peer still delivered).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A getput.Put traversal that reaches zero DHT nodes returns a nil
error even though the BEP-44 item never landed. PutInfohashPointer
and PutPPMI discarded the traversal stats and reported success, so
the live companion publisher recorded a green refresh for a pointer
no subscriber could resolve. Factor the zero-node assertion into a
single shared helper (checkPutStats) and run it from all three put
paths — keyword, BEP-46 pointer, and PPMI — so the guard cannot
drift between them again.

Also in this pass:
- nextSeq: clamp BEP-44 sequence increments at MaxInt64 instead of
  raw seq+1 in all three seqToPut closures (PutPPMI was the
  reported offender).
- decodePointerValue: bound the remote-supplied BEP-46 pointer
  value at MaxValueBytes before handing it to the bencode decoder
  (signature-bound, but cheap to fail closed on malformed input).

No on-wire bytes change: rejecting oversized values and surfacing
zero-node puts as errors are local policy on already-documented
BEP-44 limits.

Regression tests: TestPutPathsFailClosedOnZeroNodes drives all
three puts against a dead bootstrap node (nil error + zero
responses) and fails on the old fail-open behavior;
TestDecodePointerValueBounds proves the size cap fires before the
decode; TestCheckPutStatsZeroNodes and TestNextSeqClampsAtMaxInt64
pin the helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…il closed)

The B-tree reader's walkToLeaves guarded only against intra-page
cycles: each child had to point strictly downward, in range, and
strictly increasing within its page. That cannot see cross-page
fan-in — pages laid out i -> {i+1, i+2} satisfy every per-page
check, yet the number of root-to-leaf paths grows Fibonacci-ally
(reproduced 5M+ Piece() calls for a 40-piece file; Find("")
defeats all range pruning). Interior bytes are not covered by the
trailer signature, so this was an attacker-controlled DoS.

Fix:
- Share a visited-set across the entire walk and fail closed the
  moment any page is reached twice. Total page fetches are now
  bounded at NumPieces and recursion depth by the page count, so
  the visited-set subsumes the previous depth budget. Honest pure
  trees consume each child exactly once, so this costs honest
  builders nothing.
- Correct the now-false "visited at most once" comment block.
- Defense-in-depth in Find: checkLeafIndices fails closed on
  duplicate leaf indices or more leaves than pieces, so records
  can never be re-verified (ed25519 + PoW SHA256) and re-appended
  even if the walk invariant were ever broken.

This worktree branch predates the partial fix on main (strict
child-index guard + depth budget, commit 08b5df0), so that guard
and its tests (walk_to_leaves_cycle_test.go,
verify_fingerprint_earlybail_test.go + the VerifyFingerprint
early-bail) are ported here verbatim and the visited-set is layered
on top — merging into main reduces to taking this version.

Regression test: walk_to_leaves_fanin_test.go builds a signed
hostile file with the i -> {i+1, i+2} layout, asserts Find("")
fails closed with the re-visit error, and asserts Piece() calls
stay within 3x NumPieces (the old code blows a 3x budget
immediately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gnedBy-only search

Three review fixes in internal/indexer:

  - safeExtract now owns closing the file reader: the io.Closer
    Close moved from handle()'s defer into the extracting child
    goroutine's defer, strictly after Extract returns. Previously,
    when the hard extract watchdog fired, handle() closed the
    reader while the wedged extractor was still reading it — a
    Read-after-Close data race on anacrolix torrent.Reader. This
    also ports the 60s hard watchdog (extractWatchdog /
    errExtractTimeout) that the race was found in; the worker
    fails the file and moves on, and the leaked goroutine closes
    the reader itself once Extract returns.
  - AllTorrentDocs / ContentDocsForInfoHash hold the global mutex
    across their deep-pagination walks; both now bound the walk
    with a maxPages ceiling scaled off a cheap count-only query
    (mirroring the Stats text-scan guard) and log if it ever
    fires, so a pathological index that never returns a short
    page cannot pin the mutex forever.
  - Search accepts a SignedBy-only request (empty Query), as the
    SearchRequest doc has always promised; a request with neither
    Query nor SignedBy is still rejected.

Regression tests: pipeline_watchdog_close_test.go (timeout must
not close the reader under a live Extract; success/panic paths
still close it), pipeline_watchdog_hard_test.go (deadline fires),
docs_walk_backstop_test.go (full-page stub index terminates at the
ceiling with a truncation warning), TestSearchSignedByOnlyQuery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mits

Fixes the confirmed 2026-06-09 review findings for the extractors unit:

- docx/odt/odp/pptx/epub: wrap every zip-entry reader in
  io.LimitReader(rc, maxDocTextBytes) before the XML/HTML parser.
  The input caps only bound the COMPRESSED bytes; a deflate bomb
  (~1032:1) could buffer its whole decompressed body inside one
  Token()/Next() call — an OOM that recover() cannot catch. PPTX and
  EPUB shrink the remaining budget per slide/chapter so total output
  is bounded too (EPUB's text budget now aligns with the 64 MiB
  output cap instead of reusing the 256 MiB input budget).
- htmltext: SetMaxBuf on the x/net/html tokenizer (default is
  unlimited) plus an internal LimitReader; ErrBufferExceeded and
  budget-EOF are graceful truncation, so one oversized chapter no
  longer poisons the rest of a book.
- archive: enforce the documented-but-dead 4 MiB cap on the
  concatenated member-name list (the tar.gz branch read names from
  the unbounded DECOMPRESSED stream), and bound the tar.gz walk at
  1 GiB decompressed so a bomb can't burn unbounded CPU skipping one
  huge member.
- exif: keep cnt/offset arithmetic in int64 so attacker-controlled
  counts can't wrap a 32-bit int past the bounds checks (verified
  under GOARCH=386).

Regression tests: TestZipDocExtractorsBoundDecompressedEntryStream
(well-formed 65 MiB bombs per format), graceful-truncation +
budget-clamp tests for extractHTMLText, name-list cap tests for both
archive branches, and overflow tables for parseIFD/readValueBytes —
all verified to fail against the pre-fix code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the CHANGELOG entry for the 11-commit fix stack, notes the
32 MiB companion acceptance limit and visited-once B-tree walk in
docs/05, rewords the stale cmd_flag comment (flag no longer fans
out to every indexer), and checks in the review report the fixes
were driven from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the prior whole-codebase review report + findings JSON (the
campaign fixed in 08b5df0) from untracked repo-root files into
docs/reviews/, dated. The follow-up 2026-06-09 review that
regression-checked it lives at CODE_REVIEW_2026-06-09.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ding 0%")

A freshly-created torrent could stick at downloading/0% instead of
seeding/100% despite the bytes being on disk. Two paths were broken:

- CLI `create --seed` used the plain AddTorrentMetaInfo (storage rooted
  at cfg.DataDir), so without `--data-dir <parent-of-root>` the post-add
  VerifyData rehashed an empty dir. It now seeds in place from <root>
  via AddTorrentMetaInfoSeedFrom, matching the GUI.

- Renamed torrents (GUI's editable Name field, on-by-default seed
  checkbox; CLI --name) stayed at 0% even on the seed-from path:
  anacrolix's default file storage resolves files under
  <base>/<info.Name>/..., which no longer points at the real bytes once
  the display name differs from the on-disk basename.

The per-torrent seed storage now keys file paths on the real on-disk
basename via storage.NewFileOpts + a custom FilePathMaker, decoupled
from info.Name, so a renamed torrent seeds from its real location while
downloaders still see the chosen name. The basename is persisted as a
new session field (content_name) so restart restores the right storage;
legacy entries fall back to info.Name.

Regression tests cover single-file and multi-file renames and the
restart/restore path; the GUI create-seed test now asserts 0 missing
bytes. Verified end-to-end with the real CLI: a renamed create --seed
restores as status=seeding, 350000/350000, missing=0. Wire-compat
untouched (local storage wiring only; infohash/.torrent bytes identical).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ook Phase 1)

Produced by a 175-agent extraction pass: 16 parallel subsystem readers,
docs-drift/TODO/surface-audit cross-checks, adversarial verification of
all 148 broken/half-finished claims (135 confirmed, 13 refuted and
dropped), and a completeness critic. Legacy tree preserved as the
legacy-snapshot branch.

Section 0 (original vision) is intentionally a stub — the Phase 2 human
gate. Section 7 lists open questions only the author can answer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reconstructed the vision section from the project name, README, and
design docs since the author said "continue" rather than editing it.
Marked PROVISIONAL with a checklist of the load-bearing calls (mission
framing, Aggregate endgame, scope exclusions, invariants, design-center
user) for the author to confirm before Phase 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fresh architecture designed from SPEC.md (not from legacy structure) via
a judge panel: three architects proposed independent designs (evolutionary
"keep the seams", hexagonal ports-and-adapters, capability-vertical); a
3-judge panel scored them; the top-ranked evolutionary backbone was
synthesized with the hexagonal swappable-Layer-D port and the vertical
frozen contracts/* golden-vector tier grafted in. An adversarial critic
returned APPROVE WITH REQUIRED FIXES; all 16 fixes were folded in
(transport-seam cycle, orphaned aggregate CLI, serve/status contradiction,
regtest-DHT config surface, 5 preservation notes, 2 logged assumptions,
L1 fail-closed watch-item).

PLAN.md is a 14-slice vertical build order: walking skeleton -> identity
-> add/download/seed -> create/sign -> Layer L -> trust/reputation ->
capability -> Layer S + wire-compat gate -> RIBLT -> Layer D -> companion
-> GUI -> Aggregate seam -> hardening.

Stops here for author review before Phase 4 (implementation), per playbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Save the full provenance behind SPEC/ARCHITECTURE/PLAN so it survives
beyond the working session:
- docs/rebuild/README.md — phase-state tracker + what the author must
  decide before Phase 4
- docs/rebuild/phase1-verification-ledger.md — how SPEC.md was extracted
  (175 agents) + per-claim verifier notes, incl. the REFUTED claims that
  must NOT be "fixed" in the rebuild
- docs/rebuild/phase3-design-record.md — the 3 architecture proposals
  considered, judge-panel scoring, and the critic's 16 required fixes
- docs/rebuild/raw/ — raw workflow JSON outputs + workflow scripts

Docs only; no product code touched. Rebuild remains paused at the human
gate before Phase 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…deliverable)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e/plan

Gate cleared 2026-07-17: all five §0 checklist items confirmed unamended;
ARCHITECTURE.md + PLAN.md approved. Phase 4 (Slice 0) begins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…shot)

Removes cmd/, internal/, go.sum and trims go.mod to the bare module. The
complete legacy implementation remains untouched on the legacy-snapshot
branch as the read-only behavioral reference (DECISIONS.md P1/P4). The
tree is rebuilt from scratch slice by slice per PLAN.md, starting with
Slice 0. dist/swartznet-legacy-v0.8.0 preserves the last legacy CLI
binary locally (dist/ is untracked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first vertical slice of the from-scratch rebuild (PLAN.md Slice 0):
one daemon, one frontend, saying hello.

- internal/config: XDG share-root resolution (./swartznet-state homeless
  fallback), Validate() with rejections-before-mkdir (§7-Q48 resolved,
  create-set unchanged), and the single SWARTZNET_UNSAFE=1 +
  testing.Testing() unsafe gate — SWARTZNET_ALLOW_REGTEST is gone and a
  tree-grep test keeps it gone.
- internal/httpapi: zero-subsystem-import server with the legacy CSRF/
  DNS-rebind guard (exact 403 bodies), 1 MiB body cap (bodylimit ⊃ CSRF
  ⊃ mux), hardening knobs, frozen full /status DTO tree (honestly
  degraded at this slice), /healthz with instance-scoped version,
  embedded web stub, bind-and-warn non-loopback semantics, and a
  double-Start guard.
- internal/daemon: single constructor, degraded-start warnings,
  teardown-aware goBG registration (no Add/Wait race), reverse-order
  idempotent Close with observable log vocabulary
  (daemon.close_begin → daemon.bg_joined → httpapi.stopped →
  daemon.close_done).
- cmd/swartznet: dispatch with the documented exit-code contract
  (0/1/2/130), temporary 'serve' scaffold (folds into 'add' in Slice 2),
  thin-HTTP-client 'status' byte-compatible with legacy output incl. the
  per-keyword table, SWARTZNET_LOG documented in help with a warning on
  unrecognized values.
- Tests: table-driven unit suites incl. a real-signal-path test
  (SIGINT/SIGTERM → exit 130) and golden /status bytes; go test -race
  clean; scripts/dod-slice0.sh reproduces the 37-check binary DoD run.
- Docs: CHANGELOG slice entry; DECISIONS.md S0-1…S0-15; SPEC §2.1
  share-root drift corrected; ARCHITECTURE.md middleware-order prose
  fixed.

Adversarially reviewed by a 4-lens workflow before commit; all findings
fixed and pinned by tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second vertical slice (PLAN.md Slice 1): the daemon loads-or-creates the
ed25519 identity and the publisher pubkey reaches /status.

- internal/identity: frozen raw-64-byte identity.key format; exact-0600
  gate (0400 rejected) and all legacy validation error strings preserved
  byte-verbatim; seed→pubkey re-derivation on load; a present-but-invalid
  key is never overwritten or regenerated (invariant #2). Auto-create is
  gated on allowCreate inside Load's create branch — the single
  enforcement site, closing the legacy stat-then-create TOCTOU. Parent
  mkdir moved to the create arm (refused loads have zero side effects);
  post-create Chmod defeats hostile umasks (pinned under umask 0277).
- daemon: identity loads before all subsystems; path-based auto-create
  rule (cleaned compare against config.Default().IdentityPath); §6 fix —
  httpapi Options.PublisherPubKey wired from identity.PublicKeyHex, and
  handleStatus renders it un-nested from any publisher collaborator.
  New log events daemon.identity_loaded / daemon.identity_load_err.
- serve --identity: explicit path load failure is fatal (exit 1);
  default-path failure degrades per SPEC §2.8 (warn + publisher-less).
- config.IdentityPath (empty = identity off); Validate never touches it.
- Tests: full legacy inventory ported to Load (perm/size/corrupt/dir/
  mismatch/symlink/umask), daemon persistence + degradation + defect-
  absence tests, hermetic XDG isolation everywhere; -race clean.
- scripts/dod-slice1.sh: 19 binary DoD checks (create/reject/recover/
  rotate/--identity); dod-slice0.sh now hermetic + pubkey-aware golden.

Adversarially reviewed (4 lenses); all findings fixed and pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Frank Dosk and others added 29 commits July 19, 2026 13:02
… publish

B9 [LOW] concurrent PATCH /capabilities could lose a field. handleSetCapabilities
did a non-atomic read-merge-write across two separately-locked collaborator calls
(Sharing() then SetSharing()), so two overlapping requests each read the same base
and the last writer clobbered the other's toggle. Fix: serialize the read-merge-
write under a new Server.capMu.

B11 [LOW] a torrent whose cached name alone exceeds the BEP-44 1000-byte value cap
made Manifest.AddHit evict the ONLY hit to an empty list and report success, so
Publish put a useless empty KeywordValue and the torrent was undiscoverable under
its own keyword. Fix: never evict the last hit — truncate its cached name to fit
(fitHitToCap), keeping a non-empty, in-cap, still-discoverable value. Regressions:
TestManifestAddHitKeepsSingleOverCapHit + the ordinary multi-hit eviction still
works.

Both binaries rebuilt; new tests race-clean. Remaining round-2: B4 (GUI follow
persistence), B7 (sync >500-record chunking), B10 (late-metadata activation),
B12 (sync-session release on peer close).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
B4 [MED] the native GUI's "Follow publisher" called CompSub.Follow directly on
the raw subscriber worker, which mutates only the in-memory follow set — so a
follow added in the GUI was silently lost on restart (LoadFollowFile read a file
the GUI never wrote). The atomic follow-file persistence lived only in the
companionAdapter used by the HTTP API.

Fix: the persisting adapter is now built unconditionally and stored on the Daemon
(d.compController); both the HTTP API and the GUI mutate follows through it via
new Daemon.FollowPublisher / UnfollowPublisher methods. The GUI dialog routes
through FollowPublisher (and now surfaces its error) instead of the raw worker,
so a GUI follow survives a restart exactly like an API follow. The HTTP API reuses
the same controller, so both frontends share one follow-file writer.

Both binaries rebuilt; daemon + companion tests green. 9 of 12 round-2 bugs fixed;
remaining: B7 (sync >500-record chunking), B10 (late-metadata activation), B12
(sync-session release on peer close).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…peer close

B10 [LOW] a magnet/infohash whose metadata arrived after a 5-minute cap was left
permanently un-downloadable. autoDownload (and autoIndex) selected on GotInfo with
a `case <-time.After(5*time.Minute): return`, so when metadata resolved later the
goroutine had already given up and nothing flipped file priorities off None (nor
indexed/minted/published) — no automatic recovery. Fix: drop the wall-clock cap;
the goroutine is already bounded by engine close (bgCtx) and torrent removal, so
it now activates/indexes whenever metadata arrives.

B12 [LOW] OnPeerClosed leaked in-flight sync sessions. On disconnect it dropped
per-peer state but never released the peer's RIBLT sync sessions, so a responder's
symbol pump kept producing to a dead PeerToken until its budget exhausted and the
SyncSession lingered in the map until the lazy reaper happened to run. Fix: a
releaseAllSyncSessions(addr) that StopPumps + forgets every session for the peer,
called from OnPeerClosed. Regression: TestOnPeerClosedReleasesSyncSessions.

Both binaries rebuilt; swarmsearch race-clean; engine suite green. 11 of 12
round-2 bugs fixed; only B7 (sync >500-record chunking) remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iff transfers

B7 [MED] a one-directional reconciliation difference larger than
MaxRecordsPerMessage (500) silently transferred ZERO records while still
reporting convergence — exactly the large-catch-up case reconciliation exists to
serve (a fresh node syncing 600 records a peer holds). onSyncNeed and
initiatorConverge each built a single BuildRecordsFrame(found) which returned
ErrSyncTooLarge for >500 records; the `if err != nil { return }` then sent
nothing (no records, no sync_end), and the initiator's session lingered until the
2-minute stale reaper while WaitSyncConverged had already reported success.

Fix: a sendRecordsChunked helper splits the records into ≤MaxRecordsPerMessage
frames and sends each (the receiver ingests every sync_records frame
independently; `missing` rides the final frame; at least one frame always ships).
Both the responder's sync_need reply and the initiator's proactive push now
chunk. Regression: TestSyncReconcileOneDirectionalOverCap (600-record one-way diff
fully reconciles).

Both binaries rebuilt; full swarmsearch suite race-clean. This completes all 12
confirmed round-2 bugs (2 HIGH, 6 MED, 4 LOW) — every one fixed + regression-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dAll (fix e2e regression)

The initial B1 fix (c8b4c60) refused a companion fetch whenever the infohash
collided with a non-companion handle. That was too aggressive: the 2-engine
companion e2e legitimately pre-adds the companion infohash as a plain handle (a
DHT-off peer-wiring shortcut), which is indistinguishable at fetch time from a
malicious collision — so dod-slice10's real-transfer scenario regressed with
"collides with a live torrent; refusing".

Refined defense, same guarantee without the false positive:
- DropCompanionTorrent still refuses to drop a non-companion handle — this is
  what actually prevents the HIGH-severity destruction of a live torrent.
- FetchCompanionTorrent no longer refuses outright; instead it gates
  h.T.DownloadAll() on h.companion, so a colliding REAL torrent's file
  priorities are never overridden. A colliding real torrent is thus never
  mutated (no drop, no priority override); the companion-index decode simply
  fails and the fetch returns an error.
- A legitimate companion fetch (fresh companion handle) still DownloadAll's; the
  e2e's pre-added plain handle downloads via its own autoDownload path.

Regression test refocused on the load-bearing protection
(TestCompanionDropGuardProtectsLiveTorrent: a companion drop never removes a
non-companion torrent). dod-slice10 back to 21/21; wirecompat/scenarios green;
both binaries rebuilt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esurrect

Round-3 adversarial hunt found 14 new bugs (0 HIGH, 10 MED, 4 LOW — a sharp
severity drop from round 2). This batch fixes 4 high-value ones.

R9+R10 [MED] DHT/PPMI decode allocation amplification. DecodeValue and DecodePPMI
capped the outer payload at 1000 bytes but then called bencode.Unmarshal, which
uses anacrolix's ~128 MiB default MaxStrLen and allocates make([]byte, declaredLen)
BEFORE reading. A 16–21-byte hostile value declaring a ~128 MiB inner string
(e.g. `d2:ih134217727:e`) forced a transient ~128 MiB allocation on the Layer-D /
Aggregate resolve paths — a remote memory-amplification DoS (Query fans out one
goroutine per indexer, so N gossiped keys → N spikes). Fix: a shared
decodeBounded helper decodes with Decoder.MaxStrLen = MaxValueBytes, so an
oversize string is rejected without the allocation. Golden vectors unaffected.
Regressions: TestDecodeValue/PPMIBoundsAllocation.

R2 [MED] `trust add <pubkey> --file <path>` silently swallowed "--file <path>"
into the label (Go flag stops at the first positional), wrote to the DEFAULT
trust store, and exited 0 — a silent data-integrity/security defect (the default
store gates auto-confirm). Fix: apply parseFlagsAllowingLeadingPositionals (the
same helper used for create/files/confirm/flag), so --file is honored before or
after the positionals.

R3 [MED] a removed torrent could resurrect in session.json. persistState
(pause/resume/set-indexing) used the unconditional session.update upsert; racing
a concurrent RemoveTorrent (which releases e.mu between deleting the handle and
the session row), it re-inserted the deleted entry, so the torrent silently
rejoined its swarm on the next restart. Fix: use updateExisting (the guarded
method built for exactly this). Regression:
TestSessionUpdateExistingDoesNotResurrect.

Both binaries rebuilt; changed packages race-clean; gofmt/vet clean. Remaining
round-3: R1 search --json schema, R4 content-doc hijack, R5-R8, R11-R14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… primary miss

R7 [MED] composite.Lookup early-returned (nil, err) on ANY primary error and
never consulted the secondary, contradicting its documented dual-read merge. But
a routine keyword miss surfaces as an error from every Getter ("not found"), and
an indexer running LayerDMode=aggregatePPMI has NO legacy BEP-44 item at all — so
a composite-mode searcher querying such an indexer got ZERO hits for a keyword it
actually publishes in its aggregate tree, defeating the migration fill-in the
composite exists for.

Fix: query both backends and merge; only fail when BOTH error. A primary "not
found" now falls through to the secondary's hits. Regression:
TestCompositePrimaryMissDoesNotDropSecondary (existing dual-write + broken-
secondary tests still pass).

Changed package race-clean; gofmt/vet clean. Remaining round-3: R1 (search --json
schema), R4 (content-doc hijack), R5/R6, R8 (dup sync_begin), R11-R14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p leak

R4 [MED] a followed publisher could overwrite the node's OWN locally-extracted
content docs and relabel torrent metadata. IndexContent had no provenance guard
(content docs carry no signer), so a companion snapshot listing an infohash the
victim already extracted overwrote c:<ih>:<file>:<chunk> with the publisher's
text; and B6's PreserveExistingSigner protected only SignedBy, so Name/FilePaths/
Size were still relabeled. Fix:
- ContentDoc.PreserveExisting (set by the subscriber): IndexContent skips a write
  whose doc already exists, so the node's own extraction is never clobbered; a
  genuinely new content doc is still imported.
- IndexTorrent's PreserveExistingSigner now SKIPS the entire write (new sentinel
  indexer.ErrForeignTorrent) when the torrent is already attributed to a DIFFERENT
  publisher — protecting Name/FilePaths, not just SignedBy.
- The subscriber treats ErrForeignTorrent as a benign skip: it skips that
  torrent's metadata AND content and does not count/fail it.
Regressions: TestIndexContentPreserveExistingBlocksOverwrite + the updated
hijack test; dod-slice10 legit-import e2e still 21/21.

R8 [MED] a duplicate sync_begin with the same txid orphaned the first symbol
pump. registerSyncSessionIfUnderCap overwrote the incumbent session WITHOUT
StopPump, so its runSyncPump goroutine became unreachable (no StopPump call site,
not released by OnPeerClosed) and streamed to a dead token to budget exhaustion —
a per-peer-cap bypass + pump leak surviving disconnect (which B12's fix could not
reach). Fix: StopPump the incumbent before replacing it. Regression:
TestReBeginStopsIncumbentPump.

Both binaries rebuilt; changed packages race-clean; gofmt/vet clean. Round-3: 7
of 14 fixed; remaining R1, R5, R6, R11-R14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…R14 dedup prune

R11 [LOW] `companion follow <pubkeyA> <pubkeyB> --api-addr X` silently discarded
the second positional AND the post-positional --api-addr (dialing the default
:7654), following only pubkeyA with no error. Fix: use
parseFlagsAllowingLeadingPositionals and require exactly one positional, so an
extra arg is a usage error and flags after the pubkey are honored.

R13 [LOW] engine.New leaked the swarm's announceWorker goroutine on the one fatal
path where loadSession returns a nil session (torrents-dir mkdir failure): that
branch did bgCancel()+cl.Close() but not swarm.Close(), and only swarm.Close
closes p.done (bgCancel does not stop announceWorker). Fix: swarm.Close() on that
path too, matching the NewClient error branch.

R14 [LOW] the subscriber's dedup maps (imported/lastIH, keyed by pubkey) were
never pruned — SubscriberWorker.Unfollow deleted only the worker's follow/lastSync
maps — so they grew one permanent entry per distinct publisher ever synced. Fix:
Subscriber.Forget(pubkey) prunes both maps; Unfollow calls it (a refollow starts
fresh).

Both binaries rebuilt; changed packages race-clean; gofmt/vet clean. Round-3: 10
of 14 fixed; remaining R1 (search --json schema), R5 (refetch efficiency), R6
(follow-file size cap), R12 (--help exit code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R6 [MED] persistFollows wrote json.MarshalIndent (pretty-printed, ~2x compact)
with no size cap, while LoadFollowFile fails closed on any file > 1 MiB. With
labels, ~8300 follows exceeded 1 MiB — below the constant's own documented ~10k —
so a valid large follow set wrote fine but was rejected WHOLESALE on the next
restart (only a log warning), silently wiping the user's entire follow list.

Fix: write COMPACT JSON (halves the on-disk size, so compact ~90 B entries put
10k follows at ~0.9 MiB) and raise the read cap to 4 MiB for comfortable headroom
even with long labels. An over-cap file still fails closed wholesale (not a silent
suffix drop). daemon tests race-clean; both binaries rebuilt.

Round-3: 11 of 14 fixed; remaining R1 (search --json schema), R5 (refetch
efficiency), R12 (per-command --help exit code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R1 [MED] `search --json` (no --swarm/--dht/--signed-by) emitted two incompatible
JSON schemas for the identical invocation: with no daemon, searchDirect dumped
indexer.SearchResponse (flat, untagged `{"Total":..,"Hits":..}`); with a daemon
holding the index lock, the T3-2 lock-fallback routed through searchViaAPI and
dumped httpapi.SearchResponse (nested `{"local":{...}}`). A scripted `... | jq
'.Total'` returned the count with no daemon and null with one. Fix: searchDirect
now converts its result to the httpapi `{"local":{...}}` envelope
(toAPILocalBlock), so both paths share one stable schema. Regression:
TestSearchDirectJSONSchemaMatchesAPI.

R12 [LOW] every subcommand's `--help`/`-h` exited 2 (usage error) while the
top-level `swartznet --help` exits 0 — `cmd --help && echo ok` never printed ok.
Go's flag package returns flag.ErrHelp (after printing usage), which was mapped
to exitUsage. Fix: a shared parseErrExit(err) maps flag.ErrHelp → exitOK and any
other parse error → exitUsage, routed through all 18 subcommand parse sites; the
companion subcommand dispatcher also handles help. A genuine parse error still
exits 2. Regression: TestSubcommandHelpExitsZero.

Both binaries rebuilt; cmd/swartznet race-clean; gofmt/vet clean. Round-3: 13 of
14 fixed; only R5 (refetch efficiency) remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anged corpus

R5 [MED] BuildFromIndex stamped GeneratedAt=now on every rebuild and the
publisher republished unconditionally each interval, so an UNCHANGED corpus still
produced a new companion infohash every interval (GeneratedAt is inside the
hashed payload). Every follower therefore re-downloaded the whole companion
.torrent and re-ran the full IndexTorrent/IndexContent ingest for the entire
corpus every interval, forever — cost scaling with corpus size × follower count,
and SyncResult.Deduped essentially never firing in production.

Fix: the publisher fingerprints the corpus (torrents + content, XOR of per-torrent
hashes — order-independent, excludes the timestamp). When a rebuild's content is
unchanged, it reuses the prior GeneratedAt so the payload — and thus the infohash
— is byte-identical; the BEP-44 pointer still re-publishes (TTL refresh) but at
the SAME infohash, so followers' pointer dedup fires and they do not re-fetch.
Content changes still mint a fresh timestamp/infohash. Regression:
TestPublisherReusesInfohashWhenCorpusUnchanged (drop-on-change test still passes).

Both binaries rebuilt; companion race-clean; dod-slice10 21/21; gofmt/vet clean.
Round-3 COMPLETE: all 14 confirmed bugs fixed + regression-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The B7/R7 records-chunking regression test passed in isolation (~1.6s under -race)
but flaked in the full multi-package -race run: CPU starvation from all packages
running at once slowed the heavy 600-record reconciliation past its 15s deadline
(it reached 325/600 — still converging, records were NOT dropped, so this is a
timing flake, not a chunking bug). Reduce the diff to 520 records (still > the
500-record MaxRecordsPerMessage cap, so 2-chunk delivery is exercised) and raise
the deadline to 40s. Verified passing with the 5 heaviest -race packages running
concurrently. Product code unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion, sync pump

R4-hunt found 12 more bugs (0 HIGH, 8 MED, 4 LOW — severity plateaued at MED since
round 2). This batch fixes 4 clear, self-contained ones.

#1 [MED] DNS-rebind read leak. withCSRFGuard exempted GET/HEAD/OPTIONS BEFORE the
loopback-Host check the file advertises as its DNS-rebind defense, so a rebind
attacker (evil.com -> 127.0.0.1, fetch http://evil.com:7654/status) could read the
node's torrent list, publisher pubkey, published keywords, and reputation. Fix:
run the Host check for EVERY method; the Origin/Referer cross-origin CSRF check
stays write-only. Matches the documented localhost model (writes already required
a loopback Host). Test cases updated + a GET-rebind case added.

#2 [MED] crawl-probe bound its query socket to 127.0.0.1, so the kernel refused to
send to any non-loopback DHT node — the tool could not probe a single real node
(its entire purpose). Fix: bind 0.0.0.0 like the crawl command.

#3 [MED] watchCompletion added COMPANION bookkeeping-torrent infohashes to the
known-good Bloom on completion, monotonically filling the fixed-size spam-signal
filter (violating companion isolation, eroding Layer-D scoring). Fix: gate the
Bloom auto-confirm on !h.companion (promotion stays unconditional per §6),
mirroring autoIndex's guard.

#12 [LOW] StartSync's registerSyncSession overwrote an incumbent session at the
same txid WITHOUT StopPump (initiator/responder txid spaces collide), orphaning
its pump goroutine. Fix: StopPump the incumbent before replacing, mirroring
registerSyncSessionIfUnderCap.

Both binaries rebuilt; changed packages race-clean; web smoke 21/21; gofmt/vet
clean. Round-4: 4 of 12 fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…torrents

#6 [MED] completes the R4 authorship-hijack class. The ErrForeignTorrent guard
only fired when a DIFFERENT NON-EMPTY signer was already stored, so a followed
publisher could still overwrite the Name/FilePaths/Size + attribution of a
torrent the node downloaded from a plain magnet (SignedBy "") — the common case:
the node's own "Ubuntu ISO" gets relabeled "FREE MONEY" attributed to the
attacker in its Layer-L index.

Fix: IndexTorrent (PreserveExistingSigner) now checks doc EXISTENCE
(bleve.Document) — if the node already holds the torrent under ANY different
signer, including unsigned (stored ""), it returns ErrForeignTorrent and skips
the whole write; the subscriber then skips its content too. A new torrent (no
doc) or one already attributed to the same publisher is stamped normally.
Regression: TestIndexTorrentPreserveExistingBlocksUnsignedRelabel; existing
hijack/content tests + dod-slice10 legit-import e2e (21/21) still pass.

Both binaries rebuilt; indexer/companion race-clean; gofmt/vet clean. Round-4: 5
of 12 fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the round-4 adversarial backlog; each fix has a regression test that
fails when the fix is neutralized. All touched packages race-clean.

- #10 companion: put the new BEP-46 pointer BEFORE dropping the old seed, so a
  failed put keeps the still-advertised infohash fetchable.
- #7 dhtindex: TOCTOU between off-lock aggregate Refresh and a concurrent
  Retract — added a gen counter + post-distribute re-check so a retracted tree
  isn't left published for a full refresh interval.
- #9 indexer/httpapi/daemon: a malformed query string is now a typed
  ErrBadQuery → HTTP 400 (client fault), not 500; classified at the daemon
  seam via a LocalBadRequest flag (httpapi imports no indexer types).
- #5 companion: publisher GeneratedAt is now strictly monotonic across content
  changes, so a backward wall-clock step no longer trips the follower's replay
  guard and drops legitimate new content.
- #11 companion: an Unfollow racing an in-flight Sync no longer resurrects the
  dedup maps — per-publisher Forget epoch, re-checked under lock in commitDedup.
- #4 engine: persistAdd runs after Add releases e.mu; a racing RemoveTorrent
  could be resurrected — updateGuarded(abort) + Handle.isRemoved() make the
  create happens-before-correct (RemoveTorrent closes h.removed before its
  lock-guarded remove); persistSeedAdd DataPath refinement → updateExisting.
- #8 swarmsearch: initiator/responder txid collision deferred with a fix sketch
  (needs a wire direction bit + the unwired StartSync path) — see DECISIONS T3-8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convergence hunt (7 surfaces, adversarially verified). The dedicated
regression finder cleared the round-4 fixes (no new defects); six NEW
distinct bugs fixed, each with a neutralize-to-fail test.

- HIGH (contracts/ltepwire): every sn_search decoder used unbounded
  bencode.Unmarshal (~128 MiB default MaxStrLen); a ~32-byte frame
  declaring a huge inner string allocated ~120 MiB before failing, on
  every inbound frame across up to 256 workers — remote OOM DoS. Added
  decodeBounded (MaxStrLen = len(payload)) across all 11 decoders, the
  ltepwire counterpart of dhtschema.decodeBounded.
- MED (engine/queue): promoteQueued unconditionally counted an activated
  handle; a queued COMPLETE seed consumed a slot and starved a real
  download. Count only when isActive().
- MED (companion/publisher): a pointer-put failure after a content change
  left the freshly-seeded companion torrent seeded forever (referenced by
  no live pointer). Drop it on failure unless still advertised.
- LOW (engine/index): re-enabling SetTorrentIndexing never re-wrote the
  torrent-level doc. Extracted writeTorrentDoc, called from both paths.
- LOW (engine/companion): concurrent FetchCompanionTorrent of one infohash
  shared a handle; per-fetcher deferred drop tore it down mid-download.
  Reference-count so the drop fires only on the last fetcher.
- LOW (contracts/snagg): DecodeLeaf/DecodeInterior pre-allocated from an
  untrusted uint16 count (~10 MB / ~2 MB). Cap via capHint(n, remaining).

All touched + downstream packages race-clean; both binaries rebuilt.
Convergence: r2=2 HIGH, r3-4=0 HIGH, r5=1 HIGH — a real distinct DoS, so
NOT converged; a round 6 is warranted. See DECISIONS log 2026-07-19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decode-audit-led hunt (the class that produced the r5 HIGH). Each fix has a
neutralize-to-fail regression test; all touched + downstream race-clean.

- MED (contracts/snagg): DecodeRecord used unbounded bencode.Unmarshal — a
  ~15-byte hostile Aggregate-tree record forced a ~128 MiB alloc via an
  unauthenticated leaf page (the r5 snagg fix only capped the outer make-hint,
  not this inner record decode). Bounded to MaxStrLen = len(b).
- MED (internal/dhtindex): the Layer-D indexer set (NotePublisherSeen) was
  uncapped and fed from untrusted synced-record pubkeys — a flood grows it and
  per-query DHT fanout without bound. Cap the auto/gossip set at 128 with FIFO
  eviction; explicit operator indexers exempt.
- MED (internal/engine): residual TOCTOU in the r5 companion-fetch refcount —
  releaseCompanionFetch dropped the shared torrent outside companionFetchMu, so
  a concurrent fetcher could attach in the gap and hold a torn-down handle.
  retainAndAttachCompanion makes retain+attach and release+drop each atomic
  under the lock (order companionFetchMu -> e.mu). Added a -race stress test.
- MED (internal/swarmsearch): the responder enforced FileHits/ContentHits only
  against explicit scope, never the response payload — scope="n" leaked per-file
  paths + content matches. hitsToWire now enforces the node's own caps.

Convergence: r2=2 HIGH, r3-4=0 HIGH, r5=1 HIGH, r6=0 HIGH. The decode audit
closed the last alloc-amplification sites (dhtschema, ltepwire, snagg x2). The
r5-regression finder caught the engine self-regression above. See DECISIONS
2026-07-19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Targeted the un-hunted document-extractor surface (parsers of downloaded,
attacker-controlled files) + persistence robustness. The web/httpapi,
r6-regression, and completeness-critic finders returned zero (r6 fixes clean).
Each fix has a neutralize-to-fail test.

- HIGH (indexer/extractors): the PDF extractor's decompressed-text cap was on
  the wrong side of the allocation — reader.GetPlainText() materializes the whole
  document's text before the io.LimitReader runs, so a small PDF whose FlateDecode
  content stream decompresses (up to ~1032:1) to gigabytes of text OOM-crashes the
  daemon (recover() can't catch a runtime OOM). Measured 2.67 GB from a 256 MiB
  bomb. Replaced with a bounded page-by-page loop that skips any page whose
  decompressed content exceeds the budget (detected via LimitReader->Discard,
  never materialized) and truncates the total at the budget.
- MED (reputation): readBloom did make([]uint64, bitsLen) from an untrusted file
  header with no bound — a crafted/corrupt known-good.bloom with an absurd but
  internally-consistent m reaches a multi-TB alloc / makeslice panic, bypassing
  loadSpamResistance's fail-safe (which only downgrades error returns) and
  crash-loops the daemon. Cap m at 2^31 bits -> fail-safe error.
- LOW (engine): session.remove discarded the saveLocked error — a removal that
  fails to persist silently resurrects the torrent on restart. remove now returns
  the error; RemoveTorrent logs it.

Convergence: r2=2 HIGH, r3-4=0 HIGH, r5=1 HIGH, r6=0 HIGH, r7=1 HIGH. Each HIGH
after r2 came from opening a NEW surface (r5 wire-decode, r7 extractors) — the
hunt is surface-limited, not depth-limited. See DECISIONS 2026-07-19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swept the two most-under-covered surfaces (the never-hunted GUI + the sibling
ZIP extractors) + testbed. Each fix has a neutralize-to-fail test.

- HIGH (gui): the Downloads tab resolved pause/resume/remove by a ROW INDEX into
  a snapshot list rebuilt every 1s from map-iteration (random) order — a poll
  between select and action retargeted the index, so Remove dropped a torrent the
  user never selected. Fixed two ways: capture the selected INFOHASH at click
  time (selectRow/selectedIH), and sort TorrentSnapshots deterministically so the
  order is stable across polls.
- MED (indexer/extractors, EPUB): the per-chapter budget was output-text-based,
  so a chapter that decompresses gigabytes but emits no text (a <script> body)
  never shrank it -> ~1000x total decompression (CPU DoS + goroutine leak). Added
  a shared total-decompression budget charged by bytes consumed (countingReader).
- MED (indexer/extractors, ZIM): the cluster LRU evicted only by count (32) while
  each cluster is <=64 MiB -> ~2 GiB from a ~2 MB .zim. Added an aggregate byte
  budget (128 MiB) to the eviction.
- MED (testbed): s12's step-3 publish-wait was a `while true` with no timeout; the
  intended timeout+fail was orphaned in a `while false` block, so a Layer-D
  publisher regression hung the whole testbed run forever. Moved the budget check
  into the live loop.

Convergence: r2=2 HIGH, r3-4=0, r5=1, r6=0, r7=1, r8=1 — again a NEW surface
(GUI). The extractor class keeps yielding (PDF r7, EPUB+ZIM r8) because each
extractor has its own decompression-bound gap. See DECISIONS 2026-07-19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First 0-HIGH round since the GUI surface opened. The r8-regression and
html-xml-walker finders returned zero. Three of four fixes are neutralize-to-
fail pinned; the LOW #4 fix is structurally verified (see below).

- MED (gui): the Status tab ran indexer.Stats() (full-corpus scan under the
  index lock) synchronously on the Fyne UI thread every 2s, freezing the whole
  GUI on a real corpus. Compute render() off the UI thread; only SetText on it.
- MED (swarmsearch): handlePeerAnnounce (async worker) unconditionally created a
  p.peers entry, so a frame processed after the synchronous OnPeerClosed
  resurrected a zombie -> unbounded, remote-triggerable memory leak (no reaper).
  Update-only now: NotePeerAdded already creates the entry synchronously before
  any message, so a missing entry means the peer is gone -> skip.
- LOW (gui): the Search tab gated rendering on the LIVE checkbox, so toggling
  Swarm/DHT off mid-search dropped already-fetched hits. Gate on res.Swarm/res.DHT
  (what was queried; nil when not).
- LOW (engine): SetFilePriority on a queued torrent could download outside the
  max-active-downloads cap (queued torrents were gated only by None priorities).
  Add the anacrolix-level gate pause uses: DisallowDataDownload on queue,
  AllowDataDownload on promotion. Structurally verified (the flag is unexported;
  the full queue/promote/pause suite passes with no regression).

Convergence: r2=2 HIGH, r3-4=0, r5=1, r6=0, r7=1, r8=1, r9=0. Strongest signal
yet — GUI + follow-ups swept, shared-walker + r8-regression dry. See DECISIONS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…converged

The final confirmation round (2-verifier bar, 5 cross-cutting audits) surfaced
only ONE bug, found independently by two finders: decodePointerValue
(internal/dhtindex/dht.go) decoded an untrusted BEP-46 pointer with unbounded
bencode.Unmarshal, so a 16-byte signed value declaring a huge inner string
forces a ~128 MiB allocation (measured 134,225,264 bytes) before failing —
reached via the companion subscriber's hourly pointer resolution from a followed
(untrustable) publisher. Same class as dhtschema/ltepwire/snagg/reputation, in a
sibling site the systematic decode audit missed. Fixed with the same
MaxStrLen = len(raw) bound + a neutralize-to-fail alloc test.

A tree-wide grep of every bencode.Unmarshal/NewDecoder site then confirmed
closure: the rest either re-decode our own encoded output (trusted) or decode
into map[string]Bytes (lazy raw-slice capture, no amplification — a speculative
DecodeDict bound was written, found unnecessary, and reverted to keep the frozen
codec minimal). Only decodes into typed string/[]byte fields amplify, and the
pointer decoder was the last such site on untrusted remote input.

CONVERGENCE: r2=2 HIGH, r3=0, r4=0, r5=1, r6=0, r7=1, r8=1, r9=0, r10=0. Nine
adversarial rounds fixed ~28 confirmed bugs; the whole tree (incl.
wirecompat/scenarios) is race-clean; the alloc-amplification class is closed at
5 sites; the highest-bar final round found no new confirmed defect. See DECISIONS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render docs/whitepaper.md as a polished, Bitcoin-whitepaper-styled PDF:

- docs/whitepaper.tex — self-contained LaTeX source (newtx serif, microtype,
  bold numbered section headers via titlesec, a narrow run-in abstract, clean
  hyperref bookmarks). Compiles with a stock TeX Live; 5 pages, 0 overfull /
  0 underfull boxes, no warnings.
- docs/whitepaper.pdf — the compiled deliverable.
- docs/build-whitepaper.sh — reproducible md -> tex -> pdf pipeline (splits the
  source, converts the prose with pandoc for faithful escaping, wraps it in the
  preamble, and makes the long reference file-paths breakable via \path/xurl).
- docs/Makefile — `make whitepaper` (compile), `make regen` (rebuild .tex from
  the .md), `make clean`.
- .gitignore — ignore LaTeX aux artifacts (*.aux, *.fls, *.fdb_latexmk, ...).

A 3-agent adversarial fidelity review (two independent md-vs-PDF diffs + a LaTeX
correctness pass) found the PDF faithfully reproduces the source; its only
finding was a pre-existing grammar typo in the prose ("a index" -> "an index"),
fixed here in whitepaper.md and regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native GUI could add torrents but not CREATE them — that was CLI-only
(`swartznet create`). New "Create torrent" button opens a dialog:

- File… / Folder… pickers (or a typeable source path),
- an auto-derived, editable output .torrent path (re-derives on source change
  unless the user edited it),
- optional trackers / comment / private (BEP-27),
- opt-in "Sign with my identity" (disabled when no identity is loaded),
- "Seed the content after creating" — adds the result to the RUNNING engine
  (AddTorrentBytesSeedFrom) so it appears in the list immediately.

Hashing runs off the Fyne UI thread behind a modal progress bar; all widget
mutation is inside fyne.Do. Wraps the same engine.CreateTorrentFile /
CreateTorrentOptions the CLI uses.

An adversarial review of the dialog confirmed the concurrency, signer-pointer,
seed-root, and error paths are correct, and caught one real defect that is fixed
here: the earlier "Save as…" file picker used dialog.ShowFileSave, which
os.Create()s (truncates to 0 bytes) the chosen file the moment it is picked — so
selecting an existing file and then cancelling would destroy it. Removed it
(Fyne has no non-destructive save-path picker); the output path is auto-derived
next to the source and editable.

Tests: splitList, deriveTorrentPath, and a nil-daemon no-op guard. GUI package
race-clean; both binaries rebuilt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ive parity)

Bring the "Create torrent" feature to the web UI (it was native-GUI/CLI only) and
make the node's publisher id copyable in BOTH frontends.

Create torrent (web):
- New POST /torrents/create (httpapi): CreateTorrent* DTOs + Options collaborator,
  wired in the daemon to engine.CreateTorrentFile with optional identity signing
  and seed-in-place through the running engine. Because a browser can't pick
  server-side paths, root/output are typed daemon-side paths; the handler extends
  its write deadline (http.ResponseController) so a long hash isn't cut off by the
  30s API WriteTimeout.
- Downloads-tab form: source/output (auto-derived, editable), trackers, comment,
  private (BEP-27), sign (auto-disabled when no identity), seed. Full parity with
  the native dialog's options.

Copyable publisher id (both UIs):
- Web: copyText/copyButton helpers; Copy buttons on Status and Settings→About.
- Native: a Copy button in the About dialog's Identity row (Fyne clipboard).

An adversarial review (backend/security + web-frontend + parity) found the
frontend clean and parity holding; its one MEDIUM is fixed here: a create that
SUCCEEDED but whose seed leg failed was mapped to 400 and its infohash discarded.
Now the collaborator returns the valid result with a seed_error warning (200), and
the web UI surfaces it as a warning. Also added a cheap output-dir pre-flight so a
typo'd output path fails fast instead of after hashing gigabytes.

Verified: live e2e (create+sign+seed via the API → torrent seeding in /torrents),
new unit tests (endpoint 503/400/200/partial-seed; adapter create + fail-closed
sign + output pre-check), all JS node --check, httpapi/daemon/gui race-clean, both
binaries rebuilt, docs/web-client-design.md + CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- README: Go 1.22 → 1.24 (badge + build note), add whitepaper badge and a
  docs link to whitepaper.pdf/.md, fix the build-gui example arg to `dev`.
- CHANGELOG: add the v0.9.0 (preview) release section summarizing the rebuild
  headlines above the Phase-4 slice log.
- Bump in-source dev version to v0.9.1-dev (tagged binaries are stamped via
  -ldflags at release build time).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Polish for the public repo:
- CONTRIBUTING.md — build/test workflow, the mainline-compat + UI-parity +
  MPL ground rules, commit/PR conventions.
- SECURITY.md — private vulnerability reporting via GitHub advisories, supported
  versions, and the not-anonymity non-goal made explicit.
- docs/README.md — list the whitepaper (md + PDF) as the "start here" overview
  under the architecture section.
- README: link CONTRIBUTING.md and SECURITY.md from the Contributing note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two RIBLT-sync reconciliation tests failed under CI's full `-race` sweep
with "peers never advertised reconciliation to each other" — not a regression
(they pass locally and the sync deadlines below them are a generous 10–40s) but
a too-tight pre-condition wait: waitRecon only budgeted 200×5ms = 1s for the
peer_announce round-trip, and on a runner whose cores are pinned by the
indexer/extractor packages that round-trip can take several seconds.

Switch waitRecon to a deadline-based loop (30s ceiling, 10ms poll) matching the
other waits in this file. It returns the instant both sides see the capability,
so the higher ceiling only costs wall-clock when the machine is overloaded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ot cause)

The two RIBLT-sync tests kept failing on CI even after the waitRecon deadline
was widened — and they failed AFTER the full 30s wait, which ruled out mere
starvation: a 30s localhost round-trip means the announce was *dropped*, not
delayed.

Root cause is in the test harness, not the product. handlePeerAnnounce drops
any announce for a peer it has no entry for (the anti-zombie-leak guard added
earlier — must not change), relying on the production invariant that
NotePeerAdded creates that entry synchronously before any announce can arrive.
connect() skipped NotePeerAdded and let OnRemoteHandshake create the entry, so
A's async announceWorker could deliver A's announce to B in the window between
the two OnRemoteHandshake calls — before B.peers[A] existed — and B silently
dropped it, never learning A's reconciliation cap. The window needs true
parallelism + scheduler pressure, so it only bit the loaded -race CI runner
(the large-record tests add GC pressure right before connect, widening it) and
never reproduced locally.

A scratch test with a Gosched between the handshakes reproduced it
deterministically (b.recon(A)=false); adding NotePeerAdded before the yield
makes it converge every time.

Fix: connect() now calls NotePeerAdded on both sides before OnRemoteHandshake,
mirroring production (PeerConnAdded → NotePeerAdded precedes the LTEP handshake
that triggers any announce). waitRecon's deadline is kept (10s) purely as
tolerance for announceWorker scheduling latency, with its comment corrected —
it was never a lost-announce workaround. Verified: full package 5× and the two
tests 15× green under -race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claudenstein
claudenstein merged commit 49aa831 into main Jul 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant