From ad3ea97793615d05284d1ad91ebbd213fdc573e7 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 15:51:52 -0300 Subject: [PATCH 01/58] httpapi: cover GET / root-handler closure body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing tests hit individual API endpoints but never the root "/" path that serves the embedded index.html. The HandleFunc registration alone doesn't trip Go coverage; only invoking the closure does. Add a test that GETs / and asserts a 200 with an HTML body. Server.Start 93% → 95.3%; package 97.7% → 97.9%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/httpapi/root_index_test.go | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 internal/httpapi/root_index_test.go diff --git a/internal/httpapi/root_index_test.go b/internal/httpapi/root_index_test.go new file mode 100644 index 0000000..f6760bf --- /dev/null +++ b/internal/httpapi/root_index_test.go @@ -0,0 +1,48 @@ +package httpapi_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/swartznet/swartznet/internal/httpapi" +) + +// TestRootServesIndexHTML covers the +// `mux.HandleFunc("GET /{$}", func(...) { http.ServeFileFS(...) })` +// closure body in Server.Start. A bare GET / on the server +// should return the embedded index.html. Without this test the +// closure body itself was unexecuted (the registration alone +// doesn't trip Go coverage; only invoking the handler does). +func TestRootServesIndexHTML(t *testing.T) { + t.Parallel() + idx := openTempIndex(t) + base := startServer(t, httpapi.Options{Index: idx}) + + resp, err := http.Get(base + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + // The embedded index.html must contain the document root tag. + // Looser than asserting full bytes so cosmetic UI tweaks + // don't break this test. + if !strings.Contains(strings.ToLower(string(body)), "") { + t.Errorf("body does not look like HTML: %q (first 80 bytes)", string(body[:min(80, len(body))])) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} From f57de6011948bb3cd260096926f670a09262fe3a Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 16:23:18 -0300 Subject: [PATCH 02/58] dhtindex: cover EncodeValue nil-Hits default arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-existing TestEncodeDecodeRoundTrip and TestEncodeRejectsOversized both populate v.Hits before encoding, so the `if v.Hits == nil { v.Hits = []KeywordHit{} }` default-fill arm was never exercised. Pass a fully-zero KeywordValue and verify the round trip works. EncodeValue 80% → 90%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/dhtindex/schema_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/dhtindex/schema_test.go b/internal/dhtindex/schema_test.go index dafae5f..4f64fae 100644 --- a/internal/dhtindex/schema_test.go +++ b/internal/dhtindex/schema_test.go @@ -45,6 +45,32 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { } } +// TestEncodeValueHitsNilDefaults covers EncodeValue's +// `if v.Hits == nil { v.Hits = []KeywordHit{} }` arm. Pass a +// zero-valued KeywordValue (Hits is nil, Ts is 0) — the encoder +// must fill both defaults so the round trip succeeds and the +// decoded Hits is the empty slice rather than nil. +func TestEncodeValueHitsNilDefaults(t *testing.T) { + t.Parallel() + encoded, err := dhtindex.EncodeValue(dhtindex.KeywordValue{}) + if err != nil { + t.Fatalf("EncodeValue zero value: %v", err) + } + decoded, err := dhtindex.DecodeValue(encoded) + if err != nil { + t.Fatalf("DecodeValue: %v", err) + } + if decoded.Ts == 0 { + t.Error("decoded.Ts is zero; EncodeValue should auto-fill it") + } + // Either nil or empty is acceptable in the decoded form; + // what matters is that EncodeValue did not error on nil + // Hits and the round trip worked. + if len(decoded.Hits) != 0 { + t.Errorf("decoded.Hits len = %d, want 0", len(decoded.Hits)) + } +} + func TestEncodeRejectsOversized(t *testing.T) { t.Parallel() // Stuff in 100 hits with maximum-sized names → guaranteed to From 459ba0ff19f4e77b2ab649b138883c00701becbe Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 16:54:37 -0300 Subject: [PATCH 03/58] daemon: cover httpGetClient Do-error arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing happy-path / non-200 / bad-URL tests covered most of httpGetClient.Get, but the `resp, err := g.c.Do(req)` transport- error arm stayed uncovered. Pre-cancel the context so http.Do returns context.Canceled immediately, exercising the wrapped return without depending on actual network state. Get 90% → 100%; package 96.4% → 96.7%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../daemon/bootstrap_https_adapter_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/daemon/bootstrap_https_adapter_test.go b/internal/daemon/bootstrap_https_adapter_test.go index afd0423..1b28d10 100644 --- a/internal/daemon/bootstrap_https_adapter_test.go +++ b/internal/daemon/bootstrap_https_adapter_test.go @@ -95,6 +95,24 @@ func TestHttpGetClientBadRequestURL(t *testing.T) { } } +// TestHttpGetClientDialFailure covers the +// `resp, err := g.c.Do(req)` error arm. The URL is parseable +// (so NewRequestWithContext succeeds) but the target is a +// reserved address or a closed port that http.Client cannot +// reach, surfacing a transport error rather than a non-200 +// status. We use a context that's already canceled to make +// the failure deterministic. +func TestHttpGetClientDialFailure(t *testing.T) { + t.Parallel() + c := NewHTTPSFallbackClient(time.Second) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // pre-cancel so Do returns ctx.Err() immediately + _, err := c.Get(ctx, "https://example.com") + if err == nil { + t.Error("expected error from canceled context") + } +} + // TestBootstrapAnchorCount — direct accessor coverage. Exercises // both empty and post-FallbackToHTTPS states. func TestBootstrapAnchorCount(t *testing.T) { From 647e073d919fde2aefa82efc2f561ff0a1a90151 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 17:23:24 -0300 Subject: [PATCH 04/58] daemon: cover FallbackToHTTPS nil-client default arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All pre-existing tests passed an explicit fakeHTTPSClient, so the `if client == nil { client = NewHTTPSFallbackClient(0) }` arm stayed uncovered. New test passes nil + a pre-canceled context so the substituted real client errors out immediately without making a network call. FallbackToHTTPS 97.1% → 100%; package 96.7% → 97.0%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/daemon/bootstrap_https_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/daemon/bootstrap_https_test.go b/internal/daemon/bootstrap_https_test.go index 31ac46d..79b9905 100644 --- a/internal/daemon/bootstrap_https_test.go +++ b/internal/daemon/bootstrap_https_test.go @@ -90,6 +90,23 @@ func TestFallbackToHTTPSRejectsEmptyURL(t *testing.T) { } } +// TestFallbackToHTTPSNilClientFallsBackToDefault covers +// `if client == nil { client = NewHTTPSFallbackClient(0) }`. +// The pre-existing tests all pass an explicit fakeHTTPSClient +// so the nil-client substitution arm stayed uncovered. +// Pre-cancel the context so the substituted real client errors +// out immediately rather than making a network call. +func TestFallbackToHTTPSNilClientFallsBackToDefault(t *testing.T) { + lookup := newTestLookup() + b, _ := NewBootstrap(lookup, nil, nil, nil, DefaultBootstrapOptions(), nil) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := b.FallbackToHTTPS(ctx, "https://example.com", nil); err == nil { + t.Error("expected error from canceled context with default client") + } +} + func TestFallbackToHTTPSPropagatesGetError(t *testing.T) { lookup := newTestLookup() b, _ := NewBootstrap(lookup, nil, nil, nil, DefaultBootstrapOptions(), nil) From 22ccbfed2a084dc47ee81def32c0eacd11f9da3d Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 17:52:19 -0300 Subject: [PATCH 05/58] companion: cover SignAndMineRecord PoW-error propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-existing tests covered the bad-pubkey early return and the happy-path round trip, but the `if err != nil` arm after MineRecordPoW was untested. Pass bits=41 so MineRecordPoW's "cost prohibitive" guard fires; verify the wrapped error surfaces and the Sig stays zero (signing skipped). SignAndMineRecord 88.9% → 100%; package 93.5% → 93.7%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/pow_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/companion/pow_test.go b/internal/companion/pow_test.go index 4534e59..fa270cf 100644 --- a/internal/companion/pow_test.go +++ b/internal/companion/pow_test.go @@ -100,6 +100,26 @@ func TestSignAndMineRecordRejectsBadPubLen(t *testing.T) { } } +// TestSignAndMineRecordPropagatesPoWError covers the +// `if err != nil` arm after MineRecordPoW. Pass bits=41 so +// MineRecordPoW's "cost prohibitive" guard fires; the wrapped +// error must surface from SignAndMineRecord without producing +// a signed record. +func TestSignAndMineRecordPropagatesPoWError(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + var ih [20]byte + r, err := SignAndMineRecord(priv, pub, "x", ih, 0, 41) + if err == nil { + t.Fatal("expected error for prohibitive bits=41") + } + // The returned record is the partial mined value; its Sig + // must remain zero since signing was skipped. + var zeroSig [64]byte + if r.Sig != zeroSig { + t.Error("Sig should be zero on PoW failure (signing skipped)") + } +} + // Sanity: recordPreimage and RecordSigMessage MUST produce // identical bytes — otherwise a miner would solve for one preimage // and a verifier would check a different one. From 5e1ba8b801a2467f258b489c183f810756b69797 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 18:50:28 -0300 Subject: [PATCH 06/58] companion: cover VerifyFingerprint count-mismatch arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-existing tampered-leaf test triggers either the DecodeLeaf error path or the fingerprint-bytes mismatch — but the explicit `count != trailer.NumRecords` guard runs first and could mask either of those depending on the tampered byte. Add a test that inflates only the in-memory trailer's NumRecords claim (post-OpenBTree, no leaf mutation) so the fingerprint hash still matches — leaving the count guard as the only catch. VerifyFingerprint 81.5% → 85.2%; package 93.7% → 93.8%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/read_btree_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/internal/companion/read_btree_test.go b/internal/companion/read_btree_test.go index e5db433..edd95cc 100644 --- a/internal/companion/read_btree_test.go +++ b/internal/companion/read_btree_test.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "fmt" "sort" + "strings" "testing" ) @@ -204,6 +205,29 @@ func TestVerifyFingerprintDetectsTamperedLeaf(t *testing.T) { } } +// TestVerifyFingerprintCountMismatch covers the +// `if uint64(count) != r.trailer.NumRecords` arm of +// VerifyFingerprint. Build a real tree, then artificially +// inflate the trailer's NumRecords claim by one. The +// fingerprint hash itself still matches (we don't touch leaves) +// so the count-vs-claim check is the only thing that catches +// the mismatch. +func TestVerifyFingerprintCountMismatch(t *testing.T) { + r, _, _, _ := buildTestTree(t, 30, []string{"alpha", "beta"}, MinPieceSize) + // Inflate the verified trailer's NumRecords. The fingerprint + // bytes still match what's reconstructible from the leaves + // because we don't mutate any leaf — but the explicit count + // guard runs before the fingerprint comparison. + r.trailer.NumRecords++ + err := r.VerifyFingerprint() + if err == nil { + t.Fatal("expected error for inflated NumRecords") + } + if !strings.Contains(err.Error(), "trailer claims") { + t.Errorf("error = %q, want it to mention 'trailer claims'", err.Error()) + } +} + // Read what we built — a full round-trip: write a known set of // (kw, ih) pairs, read them back grouped by kw, and compare by // sorted infohash. From 8f8fe66da2269ba7f8d63cf3e86c3a9f9b9e7f67 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 19:18:46 -0300 Subject: [PATCH 07/58] companion: cover Find PoW-skip arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build a tree whose records carry tiny nonces (0..9) that don't satisfy any meaningful PoW threshold, then post-OpenBTree set the in-memory trailer's MinPoWBits to 20. Find must silently drop every record via VerifyRecordPoW's continue arm and return zero hits — verifies that the spam-defence threshold is actually enforced at query time, not just at trailer-decode. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/read_btree_test.go | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal/companion/read_btree_test.go b/internal/companion/read_btree_test.go index edd95cc..d25624e 100644 --- a/internal/companion/read_btree_test.go +++ b/internal/companion/read_btree_test.go @@ -205,6 +205,36 @@ func TestVerifyFingerprintDetectsTamperedLeaf(t *testing.T) { } } +// TestFindDropsRecordsBelowMinPoW covers Find's +// `if err := VerifyRecordPoW(rec, ...); err != nil { continue }` +// arm. Build a tree with no PoW (records minted with tiny +// nonces 0..n that don't satisfy any meaningful threshold). +// Then post-OpenBTree, set the in-memory trailer's MinPoWBits +// to 20 so VerifyRecordPoW rejects every record. Find must +// return an empty slice rather than the matching records. +func TestFindDropsRecordsBelowMinPoW(t *testing.T) { + r, _, _, _ := buildTestTree(t, 10, []string{"ubuntu"}, MinPieceSize) + // Without modifying the tree, Find returns 10 records. + hits, err := r.Find("ubuntu") + if err != nil { + t.Fatalf("Find pristine: %v", err) + } + if len(hits) != 10 { + t.Fatalf("baseline len = %d, want 10", len(hits)) + } + // Now flip the threshold: records minted with nonce=0..9 do + // not satisfy a 20-bit PoW (chance is 2^-20). Find must + // silently skip every one and return zero hits. + r.trailer.MinPoWBits = 20 + hits, err = r.Find("ubuntu") + if err != nil { + t.Fatalf("Find with PoW=20: %v", err) + } + if len(hits) != 0 { + t.Errorf("expected 0 hits with MinPoWBits=20, got %d", len(hits)) + } +} + // TestVerifyFingerprintCountMismatch covers the // `if uint64(count) != r.trailer.NumRecords` arm of // VerifyFingerprint. Build a real tree, then artificially From 03a714e84de1a3ab23becb54a4eac930d95fd133 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 19:46:45 -0300 Subject: [PATCH 08/58] companion: cover Find bad-signature skip arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build a tree where one record's signature is corrupted before BuildBTree (the trailer signs the resulting fingerprint, so OpenBTree still succeeds). Find must drop the bad-sig record via the VerifyRecordSig continue arm rather than surface a bogus hit. Find 80.6% → 83.9%; package 93.8% → 93.9%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/read_btree_test.go | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/companion/read_btree_test.go b/internal/companion/read_btree_test.go index d25624e..544b6e7 100644 --- a/internal/companion/read_btree_test.go +++ b/internal/companion/read_btree_test.go @@ -205,6 +205,47 @@ func TestVerifyFingerprintDetectsTamperedLeaf(t *testing.T) { } } +// TestFindDropsRecordsWithBadSig covers Find's +// `if err := VerifyRecordSig(rec); err != nil { continue }` +// arm. Build records normally, corrupt one record's signature +// before passing to BuildBTree (the trailer is signed over the +// resulting fingerprint, so OpenBTree still succeeds), and +// observe that Find returns one fewer hit than the input set. +func TestFindDropsRecordsWithBadSig(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + recs := makeRecords(t, pub, priv, 5, []string{"ubuntu"}) + // Corrupt the third record's signature. The leaf decode + // still succeeds because Sig is just 64 raw bytes, but + // VerifyRecordSig will fail. + recs[2].Sig[0] ^= 0xFF + + var pk [32]byte + copy(pk[:], pub) + out, err := BuildBTree(BuildBTreeInput{ + Records: recs, + PubKey: pk, + PrivKey: priv, + Seq: 1, + PieceSize: MinPieceSize, + CreatedTs: 1712649600, + }) + if err != nil { + t.Fatalf("BuildBTree: %v", err) + } + src := &BytesPageSource{Data: out.Bytes, PieceSize: MinPieceSize} + r, err := OpenBTree(src) + if err != nil { + t.Fatalf("OpenBTree: %v", err) + } + hits, err := r.Find("ubuntu") + if err != nil { + t.Fatalf("Find: %v", err) + } + if len(hits) != 4 { + t.Errorf("Find returned %d hits, want 4 (one record dropped due to bad sig)", len(hits)) + } +} + // TestFindDropsRecordsBelowMinPoW covers Find's // `if err := VerifyRecordPoW(rec, ...); err != nil { continue }` // arm. Build a tree with no PoW (records minted with tiny From 5006b51c9c1b002262b0f0ced8cef7114d34a781 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 20:15:34 -0300 Subject: [PATCH 09/58] engine: cover CreateTorrentFile CreateTorrent-error propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass an opts.Root that doesn't exist so the inner CreateTorrent fails on os.Stat; the wrapping CreateTorrentFile must surface the error without writing any file. Closes the propagation arm no other test reached. CreateTorrentFile 88.9% → 94.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/engine/create_errors_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/engine/create_errors_test.go b/internal/engine/create_errors_test.go index 7c43bbb..06cda10 100644 --- a/internal/engine/create_errors_test.go +++ b/internal/engine/create_errors_test.go @@ -41,6 +41,25 @@ func TestCreateTorrentFileRenameFailure(t *testing.T) { } } +// TestCreateTorrentFileMissingRootPropagates covers the +// `mi, err := e.CreateTorrent(opts); if err != nil` arm in +// CreateTorrentFile. Pass an opts.Root that doesn't exist so +// CreateTorrent fails on os.Stat — the wrapping function must +// surface the error without writing any file. +func TestCreateTorrentFileMissingRootPropagates(t *testing.T) { + t.Parallel() + eng := newTestEngine(t) + dir := t.TempDir() + missing := filepath.Join(dir, "no-such-root") + out := filepath.Join(dir, "out.torrent") + if _, _, err := eng.CreateTorrentFile(engine.CreateTorrentOptions{Root: missing}, out); err == nil { + t.Error("CreateTorrentFile should fail when Root is missing") + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Errorf(".torrent should not exist after failure, stat err = %v", err) + } +} + // TestCreateTorrentFileBadSigningKeyPropagatesError covers the // "sign:" wrapped error branch in CreateTorrentFile: SignWith // receives a private key of wrong length so signing.SignBytes From 636a73d5ddf15f159f44a50eab8183b61d37d5e5 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 20:43:34 -0300 Subject: [PATCH 10/58] engine: cover CreateTorrent WalkDir-error arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plant a 0o000 subdirectory under opts.Root so the pre-walk size pass's filepath.WalkDir receives a permission-denied ReadDir error. CreateTorrent must surface "stat tree:" without proceeding to BuildFromFilePath. Skipped when running as root. CreateTorrent 87.8% → 92.7%; engine package 84.1% → 84.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/engine/create_errors_test.go | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/engine/create_errors_test.go b/internal/engine/create_errors_test.go index 06cda10..beb80c1 100644 --- a/internal/engine/create_errors_test.go +++ b/internal/engine/create_errors_test.go @@ -41,6 +41,34 @@ func TestCreateTorrentFileRenameFailure(t *testing.T) { } } +// TestCreateTorrentWalkDirFails covers CreateTorrent's +// `if err != nil { return nil, fmt.Errorf("stat tree: %w", err) }` +// arm. Plant a 0o000 subdirectory under opts.Root so +// filepath.WalkDir's pre-walk size pass receives a permission +// error from the os.ReadDir call inside. +// +// Skipped when running as root (which can read any directory) +// — the test only applies to non-privileged processes. +func TestCreateTorrentWalkDirFails(t *testing.T) { + t.Parallel() + if os.Getuid() == 0 { + t.Skip("running as root, chmod 0 doesn't deny access") + } + eng := newTestEngine(t) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "ok.bin"), []byte("hi"), 0o600); err != nil { + t.Fatal(err) + } + bad := filepath.Join(root, "denied") + if err := os.Mkdir(bad, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(bad, 0o755) }) + if _, err := eng.CreateTorrent(engine.CreateTorrentOptions{Root: root}); err == nil { + t.Error("CreateTorrent should fail when WalkDir cannot read a subdirectory") + } +} + // TestCreateTorrentFileMissingRootPropagates covers the // `mi, err := e.CreateTorrent(opts); if err != nil` arm in // CreateTorrentFile. Pass an opts.Root that doesn't exist so From 7f7caf0436584490b5cd918b87d217c6f832d6cf Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 21:11:41 -0300 Subject: [PATCH 11/58] dhtindex: cover GetInfohashPointer DHT-traversal-fails arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-cancel the context so getput.Get aborts immediately without contacting any peers, exercising the "dhtindex: get pointer ... %w" error wrap that no existing test reached. GetInfohashPointer 80% → 86.7%; package 94.4% → 94.7%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dhtindex/dht_pointer_validation_test.go | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/dhtindex/dht_pointer_validation_test.go b/internal/dhtindex/dht_pointer_validation_test.go index 27f3a1f..f83687e 100644 --- a/internal/dhtindex/dht_pointer_validation_test.go +++ b/internal/dhtindex/dht_pointer_validation_test.go @@ -54,6 +54,29 @@ func TestPutInfohashPointerSaltTooLarge(t *testing.T) { } } +// TestGetInfohashPointerDHTTraversalFails covers the +// `res, _, err := getput.Get(...); if err != nil { return ... }` +// arm. With an isolated DHT server (no peers, Passive mode) +// and a pre-canceled context, the traversal aborts immediately +// and surfaces a wrapped error. +func TestGetInfohashPointerDHTTraversalFails(t *testing.T) { + t.Parallel() + srv := newIsolatedDHTServer(t) + get, err := dhtindex.NewAnacrolixGetter(srv) + if err != nil { + t.Fatal(err) + } + + var pub [32]byte + pub[0] = 0xab + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = get.GetInfohashPointer(ctx, pub, []byte("salt")) + if err == nil { + t.Error("GetInfohashPointer with canceled ctx should error from getput.Get") + } +} + // TestGetInfohashPointerEmptySalt covers the matching empty-salt // guard on the getter side. func TestGetInfohashPointerEmptySalt(t *testing.T) { From 035bb7b907360162eb7f6192567c2f55585b317e Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 21:39:24 -0300 Subject: [PATCH 12/58] dhtindex: cover Put*/Get* DHT-traversal-fails arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the canceled-context pattern that closed GetInfohashPointer's traversal-error arm to every other DHT-bound entry point: PutInfohashPointer, AnacrolixPutter.Put, GetPPMI, and PutPPMI. Each of these wraps a getput.Put or getput.Get call whose error path was unreachable in CI without a real network. Pre-canceling the context aborts the traversal deterministically. Coverage shifts: - PutInfohashPointer: 85.0% → 90.0% - Put: 80.0% → 86.7% - GetPPMI: 80.0% → 100% - PutPPMI: 82.4% → 88.2% Package coverage: 94.7% → 95.1%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dhtindex/dht_pointer_validation_test.go | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/internal/dhtindex/dht_pointer_validation_test.go b/internal/dhtindex/dht_pointer_validation_test.go index f83687e..feef591 100644 --- a/internal/dhtindex/dht_pointer_validation_test.go +++ b/internal/dhtindex/dht_pointer_validation_test.go @@ -54,6 +54,98 @@ func TestPutInfohashPointerSaltTooLarge(t *testing.T) { } } +// TestPutInfohashPointerDHTTraversalFails covers the +// `getput.Put(...)` error arm in PutInfohashPointer. With a +// pre-canceled context the put traversal aborts before +// contacting any peers, surfacing the wrapped "put pointer" +// error. +func TestPutInfohashPointerDHTTraversalFails(t *testing.T) { + t.Parallel() + srv := newIsolatedDHTServer(t) + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + put, err := dhtindex.NewAnacrolixPutter(srv, priv) + if err != nil { + t.Fatal(err) + } + var ih [20]byte + ih[0] = 0xab + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := put.PutInfohashPointer(ctx, []byte("salt"), ih); err == nil { + t.Error("PutInfohashPointer with canceled ctx should error from getput.Put") + } +} + +// TestPutDHTTraversalFails covers AnacrolixPutter.Put's +// getput.Put error arm. Same canceled-context pattern as the +// pointer-side test. +func TestPutDHTTraversalFails(t *testing.T) { + t.Parallel() + srv := newIsolatedDHTServer(t) + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + put, err := dhtindex.NewAnacrolixPutter(srv, priv) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + val := dhtindex.KeywordValue{ + Hits: []dhtindex.KeywordHit{ + {IH: []byte(strings.Repeat("\x01", 20)), N: "ubuntu"}, + }, + } + if err := put.Put(ctx, []byte("salt"), val); err == nil { + t.Error("Put with canceled ctx should error from getput.Put") + } +} + +// TestGetPPMIDHTTraversalFails covers AnacrolixGetter.GetPPMI's +// getput.Get error arm. +func TestGetPPMIDHTTraversalFails(t *testing.T) { + t.Parallel() + srv := newIsolatedDHTServer(t) + get, err := dhtindex.NewAnacrolixGetter(srv) + if err != nil { + t.Fatal(err) + } + var pub [32]byte + pub[0] = 0xab + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := get.GetPPMI(ctx, pub); err == nil { + t.Error("GetPPMI with canceled ctx should error from getput.Get") + } +} + +// TestPutPPMIDHTTraversalFails covers AnacrolixPutter.PutPPMI's +// getput.Put error arm. +func TestPutPPMIDHTTraversalFails(t *testing.T) { + t.Parallel() + srv := newIsolatedDHTServer(t) + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + put, err := dhtindex.NewAnacrolixPutter(srv, priv) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + val := dhtindex.PPMIValue{ + IH: []byte(strings.Repeat("\x01", 20)), + } + if err := put.PutPPMI(ctx, val); err == nil { + t.Error("PutPPMI with canceled ctx should error from getput.Put") + } +} + // TestGetInfohashPointerDHTTraversalFails covers the // `res, _, err := getput.Get(...); if err != nil { return ... }` // arm. With an isolated DHT server (no peers, Passive mode) From ff8c7815ec90a4ef1dd709d383a7e0b37ff0ef54 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 22:07:50 -0300 Subject: [PATCH 13/58] swarmsearch: cover handleQuery nil-reply decode-only arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The function comment promises that passing reply=nil lets the responder run through search without trying to send back a result ("decode-only mode used in unit tests"), but no test exercised that path. Add a test that drives a successful query with nil reply and asserts the call completes without panic. handleQuery 82.9% → 85.4%; package 96.1% → 96.2%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/swarmsearch/handle_query_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/swarmsearch/handle_query_test.go b/internal/swarmsearch/handle_query_test.go index f13f721..9e23b08 100644 --- a/internal/swarmsearch/handle_query_test.go +++ b/internal/swarmsearch/handle_query_test.go @@ -63,6 +63,28 @@ func TestHandleQueryShortQueryCharges(t *testing.T) { } } +// TestHandleQueryNilReplyDecodeOnlyMode covers the +// `if reply == nil { return }` arm of handleQuery — the +// "decode-only mode used in unit tests" comment promises that +// passing a nil reply lets the responder run through search +// without trying to send back a result. We have searched OK +// (nopSearcher returns 0/nil), encoded the result, and now +// just bail without invoking reply. +func TestHandleQueryNilReplyDecodeOnlyMode(t *testing.T) { + t.Parallel() + p := swarmsearch.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + p.SetSearcher(nopSearcher{}) + + body, err := swarmsearch.EncodeQuery(swarmsearch.Query{TxID: 5, Q: "ubuntu", Limit: 5}) + if err != nil { + t.Fatal(err) + } + // Pass nil reply — handleQuery must still complete without + // panic and without sending anything (there is nowhere to + // send it). + p.HandleMessage("1.2.3.4:6881", body, nil) +} + // nopSearcher always returns no hits — enough to make handleQuery // reach the short-query check after passing the searcher==nil // guard. From 5ec1672cd17c261985d6a11ce1fcb071600d8527 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 22:36:59 -0300 Subject: [PATCH 14/58] swarmsearch: cover handleQuery reply-error arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass a reply closure that returns an error and verify handleQuery completes without panic — the function debug-logs the send failure and returns rather than retrying. handleQuery 85.4% → 90.2%; package 96.2% → 96.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/swarmsearch/handle_query_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/swarmsearch/handle_query_test.go b/internal/swarmsearch/handle_query_test.go index 9e23b08..a90f6a2 100644 --- a/internal/swarmsearch/handle_query_test.go +++ b/internal/swarmsearch/handle_query_test.go @@ -1,6 +1,7 @@ package swarmsearch_test import ( + "errors" "io" "log/slog" "testing" @@ -63,6 +64,26 @@ func TestHandleQueryShortQueryCharges(t *testing.T) { } } +// TestHandleQueryReplyErrorLogged covers handleQuery's +// `if err := reply(payloadOut); err != nil { return }` arm. +// A reply closure that returns an error must be tolerated — +// handleQuery debug-logs and returns rather than panicking +// or retrying. +func TestHandleQueryReplyErrorLogged(t *testing.T) { + t.Parallel() + p := swarmsearch.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + p.SetSearcher(nopSearcher{}) + + body, err := swarmsearch.EncodeQuery(swarmsearch.Query{TxID: 6, Q: "ubuntu", Limit: 5}) + if err != nil { + t.Fatal(err) + } + failingReply := func(_ []byte) error { + return errors.New("simulated reply send failure") + } + p.HandleMessage("1.2.3.4:6881", body, failingReply) +} + // TestHandleQueryNilReplyDecodeOnlyMode covers the // `if reply == nil { return }` arm of handleQuery — the // "decode-only mode used in unit tests" comment promises that From 0336a2921360861977973468c9b21ff0b2c31467 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 23:06:53 -0300 Subject: [PATCH 15/58] swarmsearch: fix TestOnSyncRecordsApplyError to actually trigger apply_err MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-existing test passed an empty Records slice, which makes ApplyRecords succeed (no records to validate) and silently returns through the success path — never exercising the apply_err arm the comment claims. Replace with a record whose Pk is 5 bytes instead of 32 so ApplyRecords' "bad sizes" check fires and onSyncRecords takes the err return. onSyncRecords 84.6% → 100%; package 96.4% → 96.5%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../swarmsearch/handler_sync_handlers_test.go | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/swarmsearch/handler_sync_handlers_test.go b/internal/swarmsearch/handler_sync_handlers_test.go index a616c6e..c2d5fa2 100644 --- a/internal/swarmsearch/handler_sync_handlers_test.go +++ b/internal/swarmsearch/handler_sync_handlers_test.go @@ -27,21 +27,26 @@ func TestOnSyncRecordsUnknownSession(t *testing.T) { // guard branch without panicking. } -// TestOnSyncRecordsApplyError — even when the session exists, -// ApplyRecords can fail (wrong phase, malformed counts). Exercise -// that path: register a session in PhaseIdle and feed it a -// SyncRecords frame, which is invalid for that phase. +// TestOnSyncRecordsApplyError covers the +// `records, err := sess.ApplyRecords(m); if err != nil` arm. +// Register a session at TxID=7, then feed onSyncRecords a +// frame whose record has a wrong-length Pk so ApplyRecords' +// "record[N] bad sizes" check fires; onSyncRecords must +// log+return without invoking the sink. func TestOnSyncRecordsApplyError(t *testing.T) { t.Parallel() p := New(slog.Default()) sess := NewSyncSession(7, RoleResponder, nil) p.registerSyncSession("p:1", sess) - - // Frame for an idle responder — ApplyRecords requires - // PhaseSendingRecords for the responder; PhaseIdle is the - // wrong phase, so apply returns an error and we exit via - // the apply_err branch. - p.onSyncRecords("p:1", SyncRecords{TxID: 7}) + // Record with Pk of length 5 — ApplyRecords requires 32. + // TxID matches the session so we get past the txid guard + // and into the size-check loop. + p.onSyncRecords("p:1", SyncRecords{ + TxID: 7, + Records: []SyncRecord{ + {Pk: make([]byte, 5), Ih: make([]byte, 20), Sig: make([]byte, 64)}, + }, + }) } // TestOnSyncEndUnknownSession — the handler must not panic when From 1b14499e4ae1410a5c6274157b9ba0c91a9a89a2 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sat, 25 Apr 2026 23:39:05 -0300 Subject: [PATCH 16/58] companion: cover Find piece-fetch error arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a small pieceErrSource / leafFailSource pair of test fakes that wrap the real BytesPageSource and synthetically fail one piece. After OpenBTree completes (which only reads the trailer), swap the reader's source for the wrapper so Find's piece-fetch error arms fire deterministically: - TestFindRootPieceFetchError: piece 0 fetch fails - TestFindLeafPieceFetchError: a mid-walk leaf piece fails Find 83.9% → 87.1%; package 93.9% → 94.0%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/read_btree_test.go | 63 +++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/internal/companion/read_btree_test.go b/internal/companion/read_btree_test.go index 544b6e7..3b3b707 100644 --- a/internal/companion/read_btree_test.go +++ b/internal/companion/read_btree_test.go @@ -205,6 +205,69 @@ func TestVerifyFingerprintDetectsTamperedLeaf(t *testing.T) { } } +// pieceErrSource wraps a PageSource and returns a synthetic +// error for one specific piece index. Used to drive Find's +// per-piece I/O error arms without rebuilding the tree. +type pieceErrSource struct { + inner PageSource + failIdx int +} + +func (p *pieceErrSource) Piece(i int) ([]byte, error) { + if i == p.failIdx { + return nil, fmt.Errorf("simulated piece %d fetch error", i) + } + return p.inner.Piece(i) +} +func (p *pieceErrSource) NumPieces() int { return p.inner.NumPieces() } + +// TestFindRootPieceFetchError covers Find's +// `rootPage, err := r.src.Piece(0); if err != nil` arm. After +// a successful OpenBTree (which only reads the trailer page), +// swap the source for one that fails on piece 0 so Find's +// subsequent fetch errors before any walk. +func TestFindRootPieceFetchError(t *testing.T) { + r, _, _, _ := buildTestTree(t, 5, []string{"ubuntu"}, MinPieceSize) + r.src = &pieceErrSource{inner: r.src, failIdx: 0} + if _, err := r.Find("ubuntu"); err == nil { + t.Error("Find should fail when root piece fetch errors") + } +} + +// TestFindLeafPieceFetchError covers Find's +// `page, err := r.src.Piece(idx); if err != nil` arm for the +// leaf-walk loop. We swap the source to fail on piece 1 (the +// first leaf) after walkToLeaves has already gathered indices. +// walkToLeaves itself reads piece 0, then Find iterates the +// leaf indices and re-fetches each one — that re-fetch is the +// path under test. +func TestFindLeafPieceFetchError(t *testing.T) { + r, _, _, _ := buildTestTree(t, 5, []string{"ubuntu"}, MinPieceSize) + // Save inner so walkToLeaves can read piece 0 (the root). + // Then fail on the first leaf piece so Find's outer loop + // errors before decoding any leaf. + src := r.src + r.src = &leafFailSource{inner: src, leafFailIdx: 1} + if _, err := r.Find("ubuntu"); err == nil { + t.Error("Find should fail when a leaf piece fetch errors") + } +} + +// leafFailSource forwards Piece(0) (root) but fails on a +// designated leaf index. Used by TestFindLeafPieceFetchError. +type leafFailSource struct { + inner PageSource + leafFailIdx int +} + +func (l *leafFailSource) Piece(i int) ([]byte, error) { + if i == l.leafFailIdx { + return nil, fmt.Errorf("simulated leaf %d fetch error", i) + } + return l.inner.Piece(i) +} +func (l *leafFailSource) NumPieces() int { return l.inner.NumPieces() } + // TestFindDropsRecordsWithBadSig covers Find's // `if err := VerifyRecordSig(rec); err != nil { continue }` // arm. Build records normally, corrupt one record's signature From 7a5888366c21b1b22860b76f114fcfb1791408c5 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 00:11:14 -0300 Subject: [PATCH 17/58] companion: cover Find leaf re-fetch decode-error arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit walkToLeaves and Find both fetch the same leaf piece; the existing TestFindLeafDecodeError corrupts the leaf bytes before any read so the walk's decodeHeader trips first and Find's outer DecodeLeaf arm stays uncovered. Same problem applied to TestFindLeafPieceFetchError this iteration — use a stateful source that returns the real bytes on the first call (walkToLeaves) and garbage on the second (Find's re-fetch). Find 87.1% → 93.5%; package 94.0% → 94.2%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/read_btree_test.go | 51 +++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/internal/companion/read_btree_test.go b/internal/companion/read_btree_test.go index 3b3b707..193c113 100644 --- a/internal/companion/read_btree_test.go +++ b/internal/companion/read_btree_test.go @@ -253,21 +253,66 @@ func TestFindLeafPieceFetchError(t *testing.T) { } } -// leafFailSource forwards Piece(0) (root) but fails on a -// designated leaf index. Used by TestFindLeafPieceFetchError. +// leafFailSource fails on a specific leaf index, but only on +// the second-or-later call. walkToLeaves and Find both fetch +// the leaf piece — the first call (walkToLeaves) succeeds so +// the walk completes, the second call (Find's outer loop) +// fails so Find's leaf-fetch error arm fires. type leafFailSource struct { inner PageSource leafFailIdx int + hits int } func (l *leafFailSource) Piece(i int) ([]byte, error) { if i == l.leafFailIdx { - return nil, fmt.Errorf("simulated leaf %d fetch error", i) + l.hits++ + if l.hits >= 2 { + return nil, fmt.Errorf("simulated leaf %d fetch error", i) + } } return l.inner.Piece(i) } func (l *leafFailSource) NumPieces() int { return l.inner.NumPieces() } +// TestFindLeafDecodeErrorReFetch covers Find's +// `_, recs, err := DecodeLeaf(page); if err != nil` arm. The +// existing TestFindLeafDecodeError corrupts the leaf bytes +// before any read, so walkToLeaves's decodeHeader trips first. +// Use a stateful source that returns the real leaf on the +// walk, then garbage on Find's re-fetch — DecodeLeaf inside +// the outer loop fires. +func TestFindLeafDecodeErrorReFetch(t *testing.T) { + r, _, _, _ := buildTestTree(t, 5, []string{"ubuntu"}, MinPieceSize) + r.src = &leafGarbageSource{inner: r.src, garbageIdx: 1} + if _, err := r.Find("ubuntu"); err == nil { + t.Error("Find should fail when leaf decode produces an error on re-fetch") + } +} + +type leafGarbageSource struct { + inner PageSource + garbageIdx int + hits int +} + +func (l *leafGarbageSource) Piece(i int) ([]byte, error) { + if i == l.garbageIdx { + l.hits++ + if l.hits >= 2 { + // Return same-sized buffer of zeros — fails + // decodeHeader's magic check inside DecodeLeaf. + real, err := l.inner.Piece(i) + if err != nil { + return nil, err + } + return make([]byte, len(real)), nil + } + } + return l.inner.Piece(i) +} +func (l *leafGarbageSource) NumPieces() int { return l.inner.NumPieces() } + // TestFindDropsRecordsWithBadSig covers Find's // `if err := VerifyRecordSig(rec); err != nil { continue }` // arm. Build records normally, corrupt one record's signature From ee9fa1610cbca7c2320e6d186f7d25ddcfe9b251 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 00:38:18 -0300 Subject: [PATCH 18/58] companion: cover Find root-decode + kind-not-root arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more fakes in the source-stub family — constPieceSource returns custom bytes for piece 0 — let the remaining root- validation arms in Find fire deterministically: - TestFindRootHeaderDecodeError: piece 0 is zero-bytes so decodeHeader's magic check fails. - TestFindRootKindNotRoot: piece 0 is a syntactically valid leaf-kind page so decodeHeader succeeds but the kind check rejects. Find 93.5% → 100%; package 94.2% → 94.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/read_btree_test.go | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/internal/companion/read_btree_test.go b/internal/companion/read_btree_test.go index 193c113..02f6df3 100644 --- a/internal/companion/read_btree_test.go +++ b/internal/companion/read_btree_test.go @@ -275,6 +275,51 @@ func (l *leafFailSource) Piece(i int) ([]byte, error) { } func (l *leafFailSource) NumPieces() int { return l.inner.NumPieces() } +// TestFindRootHeaderDecodeError covers Find's +// `decodeHeader(rootPage) error` arm. Return zero-bytes for +// piece 0 so decodeHeader fails on the magic check before +// walkToLeaves runs. +func TestFindRootHeaderDecodeError(t *testing.T) { + r, _, _, _ := buildTestTree(t, 5, []string{"ubuntu"}, MinPieceSize) + r.src = &constPieceSource{inner: r.src, idx: 0, payload: make([]byte, MinPieceSize)} + if _, err := r.Find("ubuntu"); err == nil { + t.Error("Find should fail when root header decode errors") + } +} + +// TestFindRootKindNotRoot covers Find's +// `if hdr.Kind != PageKindRoot` arm. Return a leaf-kind page +// for piece 0 so decodeHeader succeeds but the kind check +// rejects. +func TestFindRootKindNotRoot(t *testing.T) { + r, _, _, _ := buildTestTree(t, 5, []string{"ubuntu"}, MinPieceSize) + // Fabricate a valid leaf-kind page (just header) for piece 0. + leafHdr := encodeHeader(PageHeader{ + Version: BTreeVersion, + Kind: PageKindLeaf, + }) + page := make([]byte, MinPieceSize) + copy(page, leafHdr) + r.src = &constPieceSource{inner: r.src, idx: 0, payload: page} + if _, err := r.Find("ubuntu"); err == nil { + t.Error("Find should fail when piece 0 is not root kind") + } +} + +type constPieceSource struct { + inner PageSource + idx int + payload []byte +} + +func (c *constPieceSource) Piece(i int) ([]byte, error) { + if i == c.idx { + return c.payload, nil + } + return c.inner.Piece(i) +} +func (c *constPieceSource) NumPieces() int { return c.inner.NumPieces() } + // TestFindLeafDecodeErrorReFetch covers Find's // `_, recs, err := DecodeLeaf(page); if err != nil` arm. The // existing TestFindLeafDecodeError corrupts the leaf bytes From b240466c8850471e2206fd41caeed9c72d98ff8d Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 01:07:44 -0300 Subject: [PATCH 19/58] indexer: cover Pipeline.handle empty-chunks skip arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Submit a .txt file whose OpenReader returns an empty stream; the plaintext extractor produces no chunks, so handle takes the `len(chunks) == 0 → skipped++ + return` branch without calling IndexContent. handle 82.4% → 88.2%; package 94.2% → 94.7%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../indexer/pipeline_handle_branches_test.go | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/indexer/pipeline_handle_branches_test.go b/internal/indexer/pipeline_handle_branches_test.go index 6487b89..ac49a3c 100644 --- a/internal/indexer/pipeline_handle_branches_test.go +++ b/internal/indexer/pipeline_handle_branches_test.go @@ -96,6 +96,44 @@ func TestPipelineHandleOpenReaderError(t *testing.T) { } } +// TestPipelineHandleEmptyChunksSkipsCounter covers the +// `if len(chunks) == 0 { skipped++; return }` arm of handle. +// Submit a .txt file whose OpenReader returns an empty +// stream; the plaintext extractor returns no chunks, so +// handle increments Skipped without indexing anything. +func TestPipelineHandleEmptyChunksSkipsCounter(t *testing.T) { + t.Parallel() + idx, err := Open(filepath.Join(t.TempDir(), "p_empty.bleve")) + if err != nil { + t.Fatal(err) + } + defer idx.Close() + + p := NewPipeline(idx, slog.New(slog.NewTextHandler(io.Discard, nil)), 0) + p.Start() + defer p.Stop() + + const ih = "dddddddddddddddddddddddddddddddddddddddd" + if !p.Submit(FileInput{ + InfoHash: ih, + Path: "empty.txt", + Size: 0, + OpenReader: func() (io.Reader, error) { + return bytes.NewReader(nil), nil + }, + }) { + t.Fatal("Submit returned false") + } + pollPipelineProcessed(t, p, ih) + st := p.Stats(ih) + if st.Skipped != 1 { + t.Errorf("Skipped = %d, want 1 for empty-chunks file", st.Skipped) + } + if st.Extracted != 0 { + t.Errorf("Extracted = %d, want 0 for empty-chunks file", st.Extracted) + } +} + // closingReader wraps a bytes.Reader with a Close method so the // pipeline's `r.(io.Closer); ok` type-assertion fires the // defer-Close branch. From e55352b967eec88f5e3cad0003663c47a008bdca Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 01:37:04 -0300 Subject: [PATCH 20/58] companion: cover walkToLeaves unexpected-kind arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build a tree, then substitute piece 1 (the leaf) with a valid trailer-kind page so the recursive walkToLeaves call decodes the header successfully but the kind check rejects since trailer is neither Interior nor Root nor Leaf. walkToLeaves 85.7% → 96.8%; package 94.4% → 94.5%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/read_btree_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/companion/read_btree_test.go b/internal/companion/read_btree_test.go index 02f6df3..8bed0f8 100644 --- a/internal/companion/read_btree_test.go +++ b/internal/companion/read_btree_test.go @@ -275,6 +275,26 @@ func (l *leafFailSource) Piece(i int) ([]byte, error) { } func (l *leafFailSource) NumPieces() int { return l.inner.NumPieces() } +// TestWalkToLeavesUnexpectedKind covers walkToLeaves's +// `if hdr.Kind != PageKindInterior && != Root` arm. Build a +// tree, then on Find's recursion swap piece 1 for a valid +// trailer-kind page so decodeHeader succeeds but the kind +// gate rejects. +func TestWalkToLeavesUnexpectedKind(t *testing.T) { + r, _, _, _ := buildTestTree(t, 5, []string{"ubuntu"}, MinPieceSize) + // Fabricate a valid trailer-kind page header (kind 0x03). + trailerHdr := encodeHeader(PageHeader{ + Version: BTreeVersion, + Kind: PageKindTrailer, + }) + page := make([]byte, MinPieceSize) + copy(page, trailerHdr) + r.src = &constPieceSource{inner: r.src, idx: 1, payload: page} + if _, err := r.Find("ubuntu"); err == nil { + t.Error("Find should fail when walkToLeaves hits a trailer-kind sub-page") + } +} + // TestFindRootHeaderDecodeError covers Find's // `decodeHeader(rootPage) error` arm. Return zero-bytes for // piece 0 so decodeHeader fails on the magic check before From 8f0e5b6c281f0d7e9882069c43aa51c1d6490621 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 02:06:26 -0300 Subject: [PATCH 21/58] indexer: cover Pipeline.handle all-chunks-failed counter arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the index between Start and Submit so every IndexContent call returns "indexer: closed". Pipeline.handle still extracts text from the input but every chunk's index attempt fails, taking the `writeErrors == len(chunks) → Failed++` counter arm rather than `Extracted++`. handle 88.2% → 100%; package 94.7% → 95.7%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../indexer/pipeline_handle_branches_test.go | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/internal/indexer/pipeline_handle_branches_test.go b/internal/indexer/pipeline_handle_branches_test.go index ac49a3c..680a04d 100644 --- a/internal/indexer/pipeline_handle_branches_test.go +++ b/internal/indexer/pipeline_handle_branches_test.go @@ -134,6 +134,49 @@ func TestPipelineHandleEmptyChunksSkipsCounter(t *testing.T) { } } +// TestPipelineHandleAllChunksFailedCounter covers the +// `writeErrors == len(chunks) → failed++` arm of handle. We +// close the underlying index between submit and processing so +// every IndexContent call fails with "indexer: closed". After +// the loop, writeErrors equals len(chunks), incrementing the +// Failed counter rather than the Extracted one. +func TestPipelineHandleAllChunksFailedCounter(t *testing.T) { + t.Parallel() + idx, err := Open(filepath.Join(t.TempDir(), "p_failed.bleve")) + if err != nil { + t.Fatal(err) + } + + p := NewPipeline(idx, slog.New(slog.NewTextHandler(io.Discard, nil)), 0) + p.Start() + defer p.Stop() + + // Close the index now so IndexContent fails for every + // submitted chunk. The pipeline will still extract text + // from the input, but every Index call returns "closed". + idx.Close() + + const ih = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + if !p.Submit(FileInput{ + InfoHash: ih, + Path: "blob.txt", + Size: 32, + OpenReader: func() (io.Reader, error) { + return bytes.NewReader([]byte("the quick brown fox jumps over")), nil + }, + }) { + t.Fatal("Submit returned false") + } + pollPipelineProcessed(t, p, ih) + st := p.Stats(ih) + if st.Failed != 1 { + t.Errorf("Failed = %d, want 1 when all chunks fail to index", st.Failed) + } + if st.Extracted != 0 { + t.Errorf("Extracted = %d, want 0 when all chunks fail", st.Extracted) + } +} + // closingReader wraps a bytes.Reader with a Close method so the // pipeline's `r.(io.Closer); ok` type-assertion fires the // defer-Close branch. From d4a9f4a5f379ab4b9a73525eed9355c490e8133b Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 02:35:37 -0300 Subject: [PATCH 22/58] companion: cover WriteCompanionFiles torrent-write fail arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-plant a non-empty directory at companion.torrent.tmp so the second atomicWrite call (after the JSON payload writes successfully) fails on truncate-open. WriteCompanionFiles 80% → 85%; package 94.5% → 94.6%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../writecompanionfiles_errors_test.go | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/companion/writecompanionfiles_errors_test.go b/internal/companion/writecompanionfiles_errors_test.go index 5975e4b..e2b26df 100644 --- a/internal/companion/writecompanionfiles_errors_test.go +++ b/internal/companion/writecompanionfiles_errors_test.go @@ -46,6 +46,35 @@ func TestWriteCompanionFilesMkdirFails(t *testing.T) { } } +// TestWriteCompanionFilesAtomicWriteTorrentFails covers the +// torrent-write error branch — JSON payload writes +// successfully but the second atomicWrite for +// companion.torrent fails because we pre-plant a non-empty +// directory at companion.torrent.tmp. +func TestWriteCompanionFilesAtomicWriteTorrentFails(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("opening a directory for writing has different semantics on Windows") + } + dir := t.TempDir() + // Plant blocker at companion.torrent.tmp so the second + // atomicWrite call (for the torrent) fails. Leave the + // JSON-payload tempfile path clean so the first + // atomicWrite succeeds. + tmp := filepath.Join(dir, "companion.torrent.tmp") + if err := os.MkdirAll(filepath.Join(tmp, "child"), 0o755); err != nil { + t.Fatal(err) + } + + _, _, err := companion.WriteCompanionFiles(dir, companion.CompanionIndex{}) + if err == nil { + t.Error("expected torrent-write error") + } + if !strings.Contains(err.Error(), "write torrent") { + t.Errorf("err = %q, want it to mention 'write torrent'", err.Error()) + } +} + // TestWriteCompanionFilesAtomicWritePayloadFails covers the // payload-write error branch — pre-plant a non-empty directory at // `/.tmp` so atomicWrite fails when it tries From e9136368c8552cd676c19610e432b1c4f006a051 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 03:39:51 -0300 Subject: [PATCH 23/58] daemon: cover IngestEndorsement bloom-policy fallback arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set EndorsementThreshold=10 so countStrongEndorsers with one endorser falls short, forcing the policy fallback. With bloom + tracker wired, the default unknown score of 0.5 clears the 0.3 threshold check inside bloomPolicy and admit fires via the "endorsed-bloom" label. IngestEndorsement 94.4% → 100%; package 97.0% → 97.3%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../daemon/bootstrap_https_adapter_test.go | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/daemon/bootstrap_https_adapter_test.go b/internal/daemon/bootstrap_https_adapter_test.go index 1b28d10..225b90b 100644 --- a/internal/daemon/bootstrap_https_adapter_test.go +++ b/internal/daemon/bootstrap_https_adapter_test.go @@ -147,6 +147,35 @@ func TestNewBootstrapNilLookup(t *testing.T) { } } +// TestIngestEndorsementBloomPolicyFallback covers the +// `if b.bloomPolicy(cand) { admit("endorsed-bloom", ...) }` +// arm. EndorsementThreshold=10 ensures countStrongEndorsers +// with a single endorser falls short, forcing the policy +// fallback. With bloom + tracker wired, bloomPolicy(cand) +// returns true via tracker.Threshold(cand, 0.3) — the +// default unknown score of 0.5 clears 0.3 — so admit fires. +func TestIngestEndorsementBloomPolicyFallback(t *testing.T) { + t.Parallel() + lookup := newTestLookup() + bf := reputation.NewBloomFilter(16, 0.01) + tr := reputation.NewTracker() + opts := DefaultBootstrapOptions() + opts.EndorsementThreshold = 10 + b, err := NewBootstrap(lookup, nil, bf, tr, opts, nil) + if err != nil { + t.Fatalf("NewBootstrap: %v", err) + } + + endorser := pubkeyBytes("endorser-1") + cand := pubkeyBytes("cand-1") + if !b.IngestEndorsement(endorser, cand) { + t.Error("IngestEndorsement should admit via bloom-policy fallback") + } + if !b.IsAdmitted(cand) { + t.Error("cand should be admitted after bloom-policy fallback") + } +} + // TestNewBootstrapZeroOptionsFillDefaults covers the four // default-fill arms (MaxTrackedPublishers, AnchorReputation, // CandidateReputation, EndorsementThreshold) plus the nil-log From f88bc874f5720e9a3b98e77399d4250c780d8b96 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 04:37:14 -0300 Subject: [PATCH 24/58] dhtindex: cover legacyQuery merge name-fill arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single indexer returns two hits for the same infohash where the first has empty Name and the second has a non-empty Name. The merge loop processes hits in slice order, so the second hit's name deterministically fills the initially-empty merged entry — exercises the `if lh.Name == "" && h.N != "" { lh.Name = h.N }` arm without relying on cross-indexer goroutine ordering. legacyQuery 92.2% → 93.8%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/dhtindex/lookup_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/dhtindex/lookup_test.go b/internal/dhtindex/lookup_test.go index 98f967c..efc4905 100644 --- a/internal/dhtindex/lookup_test.go +++ b/internal/dhtindex/lookup_test.go @@ -172,6 +172,37 @@ func TestLookupSomeIndexersSilent(t *testing.T) { } } +// TestLookupQueryMergeFillsEmptyNameWithinIndexer covers the +// `if lh.Name == "" && h.N != "" { lh.Name = h.N }` arm of +// legacyQuery's merge loop. A single indexer returns two hits +// for the same infohash where the first has empty Name and +// the second has a non-empty Name. The merge processes hits +// in slice order, so the second hit's name fills in the +// initially-empty merged entry deterministically. +func TestLookupQueryMergeFillsEmptyNameWithinIndexer(t *testing.T) { + t.Parallel() + g := newScriptedGetter() + pub := newPubkey(t) + salt, _ := dhtindex.SaltForKeyword("ubuntu") + g.set(pub, salt, dhtindex.KeywordValue{Hits: []dhtindex.KeywordHit{ + {IH: bytes.Repeat([]byte{0xaa}, 20), N: "", S: 50}, + {IH: bytes.Repeat([]byte{0xaa}, 20), N: "Ubuntu Late", S: 100}, + }}) + + l := dhtindex.NewLookup(g) + l.AddIndexer(pub, "indexer-one") + resp, err := l.Query(context.Background(), "ubuntu") + if err != nil { + t.Fatalf("Query: %v", err) + } + if len(resp.Hits) != 1 { + t.Fatalf("Hits = %d, want 1 (dedup)", len(resp.Hits)) + } + if resp.Hits[0].Name != "Ubuntu Late" { + t.Errorf("merged name = %q, want 'Ubuntu Late' (filled from later hit)", resp.Hits[0].Name) + } +} + func TestLookupQueryEmptyTokens(t *testing.T) { t.Parallel() l := dhtindex.NewLookup(newScriptedGetter()) From 01a5cdc8d2281b7bacf7721f5ad32c46179ecc55 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 05:05:19 -0300 Subject: [PATCH 25/58] dhtindex: cover legacyQuery Size/Files merge fill arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing same-indexer two-hit test so the second hit also carries non-zero Size and Files, exercising the matching `lh.Size == 0 && h.Sz > 0` and `lh.Files == 0 && h.F > 0` fill arms that no other test reached. legacyQuery 93.8% → 96.9%; package 95.1% → 95.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/dhtindex/lookup_test.go | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/internal/dhtindex/lookup_test.go b/internal/dhtindex/lookup_test.go index efc4905..c29860d 100644 --- a/internal/dhtindex/lookup_test.go +++ b/internal/dhtindex/lookup_test.go @@ -173,20 +173,22 @@ func TestLookupSomeIndexersSilent(t *testing.T) { } // TestLookupQueryMergeFillsEmptyNameWithinIndexer covers the -// `if lh.Name == "" && h.N != "" { lh.Name = h.N }` arm of +// `if lh.Name == "" && h.N != "" { lh.Name = h.N }` arm and +// the matching Size/Files fill-from-later-hit arms of // legacyQuery's merge loop. A single indexer returns two hits -// for the same infohash where the first has empty Name and -// the second has a non-empty Name. The merge processes hits -// in slice order, so the second hit's name fills in the -// initially-empty merged entry deterministically. +// for the same infohash where the first has empty Name/Size/ +// Files and the second has non-empty values for all three. +// The merge processes hits in slice order, so the second +// hit's metadata deterministically fills the initially-empty +// merged entry. func TestLookupQueryMergeFillsEmptyNameWithinIndexer(t *testing.T) { t.Parallel() g := newScriptedGetter() pub := newPubkey(t) salt, _ := dhtindex.SaltForKeyword("ubuntu") g.set(pub, salt, dhtindex.KeywordValue{Hits: []dhtindex.KeywordHit{ - {IH: bytes.Repeat([]byte{0xaa}, 20), N: "", S: 50}, - {IH: bytes.Repeat([]byte{0xaa}, 20), N: "Ubuntu Late", S: 100}, + {IH: bytes.Repeat([]byte{0xaa}, 20), N: "", S: 50, Sz: 0, F: 0}, + {IH: bytes.Repeat([]byte{0xaa}, 20), N: "Ubuntu Late", S: 100, Sz: 6 << 30, F: 12}, }}) l := dhtindex.NewLookup(g) @@ -198,8 +200,15 @@ func TestLookupQueryMergeFillsEmptyNameWithinIndexer(t *testing.T) { if len(resp.Hits) != 1 { t.Fatalf("Hits = %d, want 1 (dedup)", len(resp.Hits)) } - if resp.Hits[0].Name != "Ubuntu Late" { - t.Errorf("merged name = %q, want 'Ubuntu Late' (filled from later hit)", resp.Hits[0].Name) + hit := resp.Hits[0] + if hit.Name != "Ubuntu Late" { + t.Errorf("merged name = %q, want 'Ubuntu Late'", hit.Name) + } + if hit.Size != 6<<30 { + t.Errorf("merged Size = %d, want %d (filled from later hit)", hit.Size, int64(6<<30)) + } + if hit.Files != 12 { + t.Errorf("merged Files = %d, want 12 (filled from later hit)", hit.Files) } } From 8547d0074709bdb3528754dd1c64deb2f0df3f3f Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 05:33:14 -0300 Subject: [PATCH 26/58] dhtindex: cover legacyQuery bad-infohash-length skip arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A malformed indexer can publish a KeywordHit whose IH is not exactly 20 bytes (the field is a plain []byte with no wire- level length validation). The merge must skip such hits via the `if len(ih) != 40 { continue }` guard. Add a test that mixes a 19-byte IH with a valid 20-byte IH and asserts only the latter survives. legacyQuery 96.9% → 98.4%; package 95.4% → 95.5%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/dhtindex/lookup_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/dhtindex/lookup_test.go b/internal/dhtindex/lookup_test.go index c29860d..74e7956 100644 --- a/internal/dhtindex/lookup_test.go +++ b/internal/dhtindex/lookup_test.go @@ -172,6 +172,37 @@ func TestLookupSomeIndexersSilent(t *testing.T) { } } +// TestLookupQueryDropsBadInfoHashLength covers the +// `if len(ih) != 40 { continue }` arm of legacyQuery's merge +// loop. KeywordHit.IH is a []byte so a malformed indexer can +// publish a hit with an IH that's not exactly 20 bytes; the +// merge must skip it without indexing into the empty slot. +func TestLookupQueryDropsBadInfoHashLength(t *testing.T) { + t.Parallel() + g := newScriptedGetter() + pub := newPubkey(t) + salt, _ := dhtindex.SaltForKeyword("ubuntu") + g.set(pub, salt, dhtindex.KeywordValue{Hits: []dhtindex.KeywordHit{ + // 19-byte IH → hex string is 38 chars → skipped. + {IH: bytes.Repeat([]byte{0xaa}, 19), N: "Wrong Length", S: 50}, + // Valid 20-byte IH → kept. + {IH: bytes.Repeat([]byte{0xbb}, 20), N: "Right Length", S: 100}, + }}) + + l := dhtindex.NewLookup(g) + l.AddIndexer(pub, "indexer-one") + resp, err := l.Query(context.Background(), "ubuntu") + if err != nil { + t.Fatalf("Query: %v", err) + } + if len(resp.Hits) != 1 { + t.Fatalf("Hits = %d, want 1 (bad-length one dropped)", len(resp.Hits)) + } + if resp.Hits[0].Name != "Right Length" { + t.Errorf("kept hit name = %q, want 'Right Length'", resp.Hits[0].Name) + } +} + // TestLookupQueryMergeFillsEmptyNameWithinIndexer covers the // `if lh.Name == "" && h.N != "" { lh.Name = h.N }` arm and // the matching Size/Files fill-from-later-hit arms of From f508fd738c88edc1649a57450811ce82cfe7acd9 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 06:30:41 -0300 Subject: [PATCH 27/58] reputation: cover Tracker.Save no-path no-op arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewTracker() returns an in-memory tracker with empty path; Save's `if t.path == "" { return nil }` early-return arm let callers construct ephemeral trackers for tests / experiments without needing a backing file. No existing test exercised this path. Save 85.7% → 92.9%; package 95.0% → 95.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../reputation/tracker_save_nopath_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/reputation/tracker_save_nopath_test.go diff --git a/internal/reputation/tracker_save_nopath_test.go b/internal/reputation/tracker_save_nopath_test.go new file mode 100644 index 0000000..dbb1ed1 --- /dev/null +++ b/internal/reputation/tracker_save_nopath_test.go @@ -0,0 +1,20 @@ +package reputation_test + +import ( + "testing" + + "github.com/swartznet/swartznet/internal/reputation" +) + +// TestTrackerSaveNoPathIsNoOp covers Tracker.Save's +// `if t.path == "" { return nil }` early-return arm. An +// in-memory tracker constructed via NewTracker() has no +// backing file; Save must not error and must not create +// anything on disk. +func TestTrackerSaveNoPathIsNoOp(t *testing.T) { + t.Parallel() + tr := reputation.NewTracker() + if err := tr.Save(); err != nil { + t.Errorf("Save on in-memory tracker should be a no-op, got %v", err) + } +} From 304bc077e58f353b8e5ec14bc38832fc7d6c39c9 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 06:59:33 -0300 Subject: [PATCH 28/58] reputation: cover writeBloom write-error arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tests use a counting failingWriter that succeeds for the first N bytes then errors out: - TestWriteBloomHeaderWriteError fails at byte 0 so the header write returns immediately. - TestWriteBloomBitsWriteError accepts the 24-byte header then fails on the first bits-buffer write. writeBloom 81.2% → 93.8%; package 95.4% → 96.0%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../reputation/write_bloom_internal_test.go | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 internal/reputation/write_bloom_internal_test.go diff --git a/internal/reputation/write_bloom_internal_test.go b/internal/reputation/write_bloom_internal_test.go new file mode 100644 index 0000000..b70fbce --- /dev/null +++ b/internal/reputation/write_bloom_internal_test.go @@ -0,0 +1,58 @@ +package reputation + +import ( + "errors" + "testing" +) + +// failingWriter returns a synthetic error after writing N bytes. +// Used to drive writeBloom's two distinct Write-error arms — one +// at the header and one inside the bits-loop. +type failingWriter struct { + allowedBytes int + written int +} + +func (f *failingWriter) Write(p []byte) (int, error) { + if f.written >= f.allowedBytes { + return 0, errors.New("simulated write failure") + } + n := len(p) + if f.written+n > f.allowedBytes { + n = f.allowedBytes - f.written + } + f.written += n + if n < len(p) { + return n, errors.New("simulated short write") + } + return n, nil +} + +// TestWriteBloomHeaderWriteError covers writeBloom's first +// `if _, err := w.Write(hdr[:]); err != nil { return err }` +// arm. The writer fails before accepting any bytes so the +// header write surfaces the error. +func TestWriteBloomHeaderWriteError(t *testing.T) { + t.Parallel() + bf := NewBloomFilter(16, 0.01) + bf.Add([]byte("test")) + w := &failingWriter{allowedBytes: 0} + if err := writeBloom(w, bf); err == nil { + t.Error("writeBloom should surface header-write error") + } +} + +// TestWriteBloomBitsWriteError covers writeBloom's second +// `if _, err := w.Write(buf); err != nil { return err }` arm +// inside the bits-loop. The writer accepts the 24-byte header +// but fails on the first bits-buffer write. +func TestWriteBloomBitsWriteError(t *testing.T) { + t.Parallel() + bf := NewBloomFilter(16, 0.01) + bf.Add([]byte("test")) + // Header is 24 bytes (4 magic + 2 version + 2 k + 8 m + 8 bitslen). + w := &failingWriter{allowedBytes: 24} + if err := writeBloom(w, bf); err == nil { + t.Error("writeBloom should surface bits-loop write error") + } +} From bc7da9e10dfd6a41a5bf434cbe381d54a13c27d1 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 07:27:18 -0300 Subject: [PATCH 29/58] reputation: cover readBloom corruption + truncation arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tests use hand-crafted bloom file headers: - TestReadBloomBitsLenInconsistent declares bitsLen huge relative to m so the size-consistency guard fires before a billion-entry slice would be allocated. - TestReadBloomBitsReadError truncates the body mid-bits so io.ReadFull returns unexpected EOF on the second iteration of the bits loop. readBloom 90% → 100%; package 96.0% → 96.6%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../reputation/write_bloom_internal_test.go | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/reputation/write_bloom_internal_test.go b/internal/reputation/write_bloom_internal_test.go index b70fbce..ce00b14 100644 --- a/internal/reputation/write_bloom_internal_test.go +++ b/internal/reputation/write_bloom_internal_test.go @@ -1,6 +1,8 @@ package reputation import ( + "bytes" + "encoding/binary" "errors" "testing" ) @@ -56,3 +58,42 @@ func TestWriteBloomBitsWriteError(t *testing.T) { t.Error("writeBloom should surface bits-loop write error") } } + +// TestReadBloomBitsLenInconsistent covers readBloom's +// `if bitsLen > (m+63)/64+1` guard. Hand-craft a header whose +// declared bitsLen exceeds what m would allow, and verify +// readBloom rejects rather than allocating a huge slice. +func TestReadBloomBitsLenInconsistent(t *testing.T) { + t.Parallel() + hdr := make([]byte, 24) + copy(hdr[0:4], bloomFileMagic) + binary.LittleEndian.PutUint16(hdr[4:6], bloomFileVersion) + binary.LittleEndian.PutUint16(hdr[6:8], 4) // k=4 + binary.LittleEndian.PutUint64(hdr[8:16], 64) // m=64 → expected bitsLen ~1 + binary.LittleEndian.PutUint64(hdr[16:24], 1_000_000_000) // huge claimed bitsLen + if _, err := readBloom(bytes.NewReader(hdr)); err == nil { + t.Error("readBloom should reject bitsLen inconsistent with m") + } +} + +// TestReadBloomBitsReadError covers readBloom's +// `io.ReadFull(r, buf)` error arm inside the bits-loop. We +// supply a header whose bitsLen claims 2 entries but the +// reader has only enough bytes for the header plus 1 entry. +// The 2nd ReadFull returns io.EOF / unexpected EOF. +func TestReadBloomBitsReadError(t *testing.T) { + t.Parallel() + // m=64 → expected bitsLen = (64+63)/64 + 1 = 2. + hdr := make([]byte, 24) + copy(hdr[0:4], bloomFileMagic) + binary.LittleEndian.PutUint16(hdr[4:6], bloomFileVersion) + binary.LittleEndian.PutUint16(hdr[6:8], 4) // k=4 + binary.LittleEndian.PutUint64(hdr[8:16], 64) // m=64 + binary.LittleEndian.PutUint64(hdr[16:24], 2) // bitsLen=2 + // Provide only 8 bytes of bits (= 1 entry); the second + // ReadFull fails. + body := append(hdr, make([]byte, 8)...) + if _, err := readBloom(bytes.NewReader(body)); err == nil { + t.Error("readBloom should fail when bits truncate mid-read") + } +} From 7ece988c33a01b94657f1a6b856aa47c103f58b6 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 07:55:30 -0300 Subject: [PATCH 30/58] reputation: cover EstimatedItems saturated-filter arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saturate every bit in a small filter (set bits to all-ones) so popcount equals m and the `if x >= m { return Inf }` guard fires. Without this test the +Inf saturation path of the cardinality estimator was unexercised. EstimatedItems 90.9% → 100%; package 96.6% → 96.9%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../reputation/write_bloom_internal_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/reputation/write_bloom_internal_test.go b/internal/reputation/write_bloom_internal_test.go index ce00b14..bdd50e5 100644 --- a/internal/reputation/write_bloom_internal_test.go +++ b/internal/reputation/write_bloom_internal_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/binary" "errors" + "math" "testing" ) @@ -59,6 +60,23 @@ func TestWriteBloomBitsWriteError(t *testing.T) { } } +// TestEstimatedItemsSaturated covers EstimatedItems' +// `if x >= m { return math.Inf(1) }` arm. Saturate every +// bit in a small filter so popcount equals m. +func TestEstimatedItemsSaturated(t *testing.T) { + t.Parallel() + bf := NewBloomFilter(8, 0.5) + // Set every bit by writing all-ones into the bits slice + // directly. We're inside the package so this is allowed. + for i := range bf.bits { + bf.bits[i] = ^uint64(0) + } + got := bf.EstimatedItems() + if !math.IsInf(got, 1) { + t.Errorf("EstimatedItems on saturated filter = %v, want +Inf", got) + } +} + // TestReadBloomBitsLenInconsistent covers readBloom's // `if bitsLen > (m+63)/64+1` guard. Hand-craft a header whose // declared bitsLen exceeds what m would allow, and verify From de9508b4dd78c205b5ca024c16378056279802c9 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 08:25:04 -0300 Subject: [PATCH 31/58] companion: cover leadingZeroBitsOfByteSlice all-zero arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a direct table-driven test for the pow.go-local copy of the leading-zero counter (the read_btree.go copy already had TestLeadingZeroBits). Cases include nil input, all-zero slices, and a mixed leading-zero pattern. Closes the previously-uncovered outer-loop-exit path; the residual unreachable inner-loop fallthrough is genuine dead code (non-zero byte always has a 1-bit). leadingZeroBitsOfByteSlice 81.8% → 90.9%; package 94.6% → 94.7%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/pow_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/companion/pow_test.go b/internal/companion/pow_test.go index fa270cf..4ff15be 100644 --- a/internal/companion/pow_test.go +++ b/internal/companion/pow_test.go @@ -91,6 +91,23 @@ func TestSignAndMineRecordRoundTrip(t *testing.T) { } } +// TestLeadingZeroBitsOfByteSliceEdgeCases covers the two +// edge-case return arms of the pow.go-local copy of the +// leading-zero-counter: the all-zero input (outer loop exits +// without ever entering the inner mask loop) and the empty +// input (outer loop never iterates). +func TestLeadingZeroBitsOfByteSliceEdgeCases(t *testing.T) { + if got := leadingZeroBitsOfByteSlice(nil); got != 0 { + t.Errorf("leadingZeroBitsOfByteSlice(nil) = %d, want 0", got) + } + if got := leadingZeroBitsOfByteSlice([]byte{0x00, 0x00, 0x00}); got != 24 { + t.Errorf("leadingZeroBitsOfByteSlice(3 zeros) = %d, want 24", got) + } + if got := leadingZeroBitsOfByteSlice([]byte{0x00, 0x00, 0x40}); got != 17 { + t.Errorf("leadingZeroBitsOfByteSlice(2 zeros + 0x40) = %d, want 17", got) + } +} + func TestSignAndMineRecordRejectsBadPubLen(t *testing.T) { _, priv, _ := ed25519.GenerateKey(rand.Reader) var ih [20]byte From 92cd86df8ba742f210351c6f0fd54c6fe2d6a609 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 08:55:24 -0300 Subject: [PATCH 32/58] companion: cover packLeaves single-record-too-big arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass a 100-byte pieceSize so even a normally-sized record (~270 bytes encoded) overflows the page on its own. packLeaves must surface the "record too large" error rather than silently truncating. packLeaves 85.7% → 95.2%; package 94.7% → 94.9%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/pack_leaves_internal_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/companion/pack_leaves_internal_test.go b/internal/companion/pack_leaves_internal_test.go index 3b504dd..e52afb2 100644 --- a/internal/companion/pack_leaves_internal_test.go +++ b/internal/companion/pack_leaves_internal_test.go @@ -25,3 +25,18 @@ func TestPackLeavesRejectsOversizeKeyword(t *testing.T) { t.Error("packLeaves should reject oversize keyword") } } + +// TestPackLeavesSingleRecordTooLargeForPage covers the +// `if len(cur) == 0 { return error }` arm of packLeaves. +// Pass a tiny pieceSize so even a single normally-sized +// record can't fit; packLeaves must surface the +// "record too large" error rather than silently truncating. +func TestPackLeavesSingleRecordTooLargeForPage(t *testing.T) { + t.Parallel() + r := Record{Kw: "ubuntu"} + // 100 bytes is well below the ~270 bytes a record encodes + // to, plus the 16-byte page header. + if _, err := packLeaves([]Record{r}, 100); err == nil { + t.Error("packLeaves should reject single oversized record") + } +} From 1e41198a730cb568d9f3997735db4a31ace261dd Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 09:23:25 -0300 Subject: [PATCH 33/58] companion: cover EncodeRecord oversize-bencoded guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record with Kw at the 64-byte keyword limit plus max-width Pow (uint64-max → 20 digits) and T (int64-max → 19 digits) encodes to ~258 bytes once you sum the bencoded fixed fields (pk:38, ih:26, sig:70 etc.) — just over the 256-byte MaxRecordBytes ceiling. Verifies that the post-marshal size guard catches such records before they reach the leaf encoder. EncodeRecord 81.8% → 90.9%; package 94.9% → 95.0%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/btree_encode_guards_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/companion/btree_encode_guards_test.go b/internal/companion/btree_encode_guards_test.go index db2cb3a..689eecd 100644 --- a/internal/companion/btree_encode_guards_test.go +++ b/internal/companion/btree_encode_guards_test.go @@ -61,3 +61,21 @@ func TestEncodeRecordOversizedKeyword(t *testing.T) { t.Error("EncodeRecord should reject oversize keyword") } } + +// TestEncodeRecordExceedsByteCap covers EncodeRecord's +// `if len(out) > MaxRecordBytes { return error }` guard — +// the post-marshal size check for records whose encoded form +// pushes past the 256-byte cap. With Kw at the keyword limit +// plus max-width Pow + T values, the bencoded form of a +// recordWire crosses the boundary. +func TestEncodeRecordExceedsByteCap(t *testing.T) { + t.Parallel() + r := Record{ + Kw: strings.Repeat("k", MaxKeywordBytes), + T: 1<<63 - 1, + Pow: 1<<64 - 1, + } + if _, err := EncodeRecord(r); err == nil { + t.Error("EncodeRecord should reject record whose encoded form exceeds MaxRecordBytes") + } +} From 5a43aeee98489bb46c36061eed7eb0afbe988d5e Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 09:41:39 -0300 Subject: [PATCH 34/58] companion: cover VerifyFingerprint fetch/decode/hash arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new tests round out the VerifyFingerprint error paths: - TestVerifyFingerprintFetchError: piece-fetch fails mid-walk via the pieceErrSource wrapper. - TestVerifyFingerprintHeaderDecodeError: piece 1 returns zero-bytes via constPieceSource so decodeHeader rejects on bad magic. - TestVerifyFingerprintHashMismatch: tamper with the in-memory trailer's TreeFingerprint after OpenBTree so the reconstructed hash and the claim diverge — count guard passes, fingerprint comparison catches. VerifyFingerprint 85.2% → 96.3%; package 95.0% → 95.3%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/read_btree_test.go | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/companion/read_btree_test.go b/internal/companion/read_btree_test.go index 8bed0f8..9e6bf0f 100644 --- a/internal/companion/read_btree_test.go +++ b/internal/companion/read_btree_test.go @@ -449,6 +449,48 @@ func TestFindDropsRecordsBelowMinPoW(t *testing.T) { } } +// TestVerifyFingerprintFetchError covers VerifyFingerprint's +// `if err := r.src.Piece(i); err != nil` arm. Swap the source +// with a wrapper that fails on piece 1 (the leaf) so the walk +// errors out before any record is hashed. +func TestVerifyFingerprintFetchError(t *testing.T) { + r, _, _, _ := buildTestTree(t, 5, []string{"ubuntu"}, MinPieceSize) + r.src = &pieceErrSource{inner: r.src, failIdx: 1} + if err := r.VerifyFingerprint(); err == nil { + t.Error("VerifyFingerprint should propagate piece-fetch errors") + } +} + +// TestVerifyFingerprintHeaderDecodeError covers +// VerifyFingerprint's `if err := decodeHeader(page); err != nil` +// arm. Replace piece 1 with all-zero bytes so decodeHeader +// rejects on bad magic. +func TestVerifyFingerprintHeaderDecodeError(t *testing.T) { + r, _, _, _ := buildTestTree(t, 5, []string{"ubuntu"}, MinPieceSize) + r.src = &constPieceSource{inner: r.src, idx: 1, payload: make([]byte, MinPieceSize)} + if err := r.VerifyFingerprint(); err == nil { + t.Error("VerifyFingerprint should propagate header-decode errors") + } +} + +// TestVerifyFingerprintHashMismatch covers the +// `if got != r.trailer.TreeFingerprint` arm. Mutate the +// in-memory trailer's TreeFingerprint after OpenBTree so the +// reconstructed hash and the claim no longer match. The +// counts still match (we don't touch leaves), so the count +// guard passes and the fingerprint comparison is the +// catch-all. +func TestVerifyFingerprintHashMismatch(t *testing.T) { + r, _, _, _ := buildTestTree(t, 5, []string{"ubuntu"}, MinPieceSize) + // Flip a byte in the in-memory trailer fingerprint. The + // in-memory fingerprint diverges from what the leaves + // re-hash to, so the comparison fails. + r.trailer.TreeFingerprint[0] ^= 0xFF + if err := r.VerifyFingerprint(); err == nil { + t.Error("VerifyFingerprint should reject when reconstructed hash differs from trailer claim") + } +} + // TestVerifyFingerprintCountMismatch covers the // `if uint64(count) != r.trailer.NumRecords` arm of // VerifyFingerprint. Build a real tree, then artificially From 1404cb762c76fea909583cc6af702558897b58ca Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 09:47:55 -0300 Subject: [PATCH 35/58] swarmsearch: cover ProduceSymbols phase + count-clamp arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tests round out ProduceSymbols: - TestProduceSymbolsWrongPhase calls in PhaseIdle (fresh session, no Begin called) so the phase guard fires. - TestProduceSymbolsCountClamping exercises both count <= 0 (falls back to MaxSymbolsPerMessage default) and count > MaxSymbolsPerMessage (capped). ProduceSymbols 86.4% → 100%; package 96.4% → 96.7%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../swarmsearch/sync_session_branches_test.go | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/swarmsearch/sync_session_branches_test.go b/internal/swarmsearch/sync_session_branches_test.go index 1cf0e15..c3c795b 100644 --- a/internal/swarmsearch/sync_session_branches_test.go +++ b/internal/swarmsearch/sync_session_branches_test.go @@ -4,6 +4,48 @@ import ( "testing" ) +// TestProduceSymbolsWrongPhase covers ProduceSymbols's +// `if s.phase != PhaseBegun && s.phase != PhaseSymbolsFlowing` +// error arm. A fresh session is in PhaseIdle; ProduceSymbols +// must reject before generating any symbols. +func TestProduceSymbolsWrongPhase(t *testing.T) { + t.Parallel() + s := NewSyncSession(1, RoleResponder, nil) + if _, _, err := s.ProduceSymbols(8); err == nil { + t.Error("ProduceSymbols in PhaseIdle should error") + } +} + +// TestProduceSymbolsCountClamping covers the two count- +// clamping arms: count <= 0 falls back to MaxSymbolsPerMessage, +// and count > MaxSymbolsPerMessage gets capped. +func TestProduceSymbolsCountClamping(t *testing.T) { + t.Parallel() + s := NewSyncSession(1, RoleInitiator, nil) + if _, err := s.Begin(SyncFilter{}); err != nil { + t.Fatalf("Begin: %v", err) + } + // count = 0 → MaxSymbolsPerMessage default applied; the + // initial symbol budget is also bounded by maxSymbols, so + // this just needs to not error. + syms, _, err := s.ProduceSymbols(0) + if err != nil { + t.Fatalf("ProduceSymbols(0): %v", err) + } + if len(syms) == 0 || len(syms) > MaxSymbolsPerMessage { + t.Errorf("count<=0 path: got %d symbols, want 1..%d", len(syms), MaxSymbolsPerMessage) + } + + // count > MaxSymbolsPerMessage → capped. + syms2, _, err := s.ProduceSymbols(MaxSymbolsPerMessage + 100) + if err != nil { + t.Fatalf("ProduceSymbols(huge): %v", err) + } + if len(syms2) > MaxSymbolsPerMessage { + t.Errorf("count too large: got %d symbols, want <= %d", len(syms2), MaxSymbolsPerMessage) + } +} + // TestSyncSessionFinishEmptyStatus covers Finish's empty-string // default arm: passing "" must rewrite to SyncStatusConverged. func TestSyncSessionFinishEmptyStatus(t *testing.T) { From 4d2db1701f578708517c1f71c829d2ea9d1bad82 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 09:52:14 -0300 Subject: [PATCH 36/58] swarmsearch: cover ApplyNeed txid + too-many-ids guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestApplyNeedTxIDMismatch passes a SyncNeed whose TxID diverges from the session's so the txid guard fires. - TestApplyNeedTooManyIDs passes IDs of length MaxNeedIDsPerMessage+1 so the per-message cap rejects. ApplyNeed 86.7% → 100%; package 96.7% → 96.9%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../swarmsearch/sync_session_branches_test.go | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/internal/swarmsearch/sync_session_branches_test.go b/internal/swarmsearch/sync_session_branches_test.go index c3c795b..a4294bd 100644 --- a/internal/swarmsearch/sync_session_branches_test.go +++ b/internal/swarmsearch/sync_session_branches_test.go @@ -4,6 +4,30 @@ import ( "testing" ) +// TestApplyNeedTxIDMismatch covers ApplyNeed's +// `if m.TxID != s.txid` arm. +func TestApplyNeedTxIDMismatch(t *testing.T) { + t.Parallel() + s := NewSyncSession(7, RoleResponder, nil) + if _, _, err := s.ApplyNeed(SyncNeed{TxID: 99}); err == nil { + t.Error("ApplyNeed with mismatched TxID should error") + } +} + +// TestApplyNeedTooManyIDs covers the +// `if len(m.IDs) > MaxNeedIDsPerMessage` cap arm. +func TestApplyNeedTooManyIDs(t *testing.T) { + t.Parallel() + s := NewSyncSession(7, RoleResponder, nil) + tooMany := make([][]byte, MaxNeedIDsPerMessage+1) + for i := range tooMany { + tooMany[i] = make([]byte, 32) + } + if _, _, err := s.ApplyNeed(SyncNeed{TxID: 7, IDs: tooMany}); err == nil { + t.Error("ApplyNeed should reject IDs slice exceeding the cap") + } +} + // TestProduceSymbolsWrongPhase covers ProduceSymbols's // `if s.phase != PhaseBegun && s.phase != PhaseSymbolsFlowing` // error arm. A fresh session is in PhaseIdle; ProduceSymbols From 1856c145c7064f0954c6420644fff7b66a2d9ac4 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 09:57:02 -0300 Subject: [PATCH 37/58] swarmsearch: cover DecodeSyncBegin wrong-msg-type arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bencode-marshal a payload that round-trips into a valid SyncBegin struct but carries MsgTypeSyncEnd as msg_type so the discriminator-mismatch guard fires. DecodeSyncBegin 87.5% → 100%; package 96.9% → 97.0%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sync_wire_decode_msgtype_test.go | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 internal/swarmsearch/sync_wire_decode_msgtype_test.go diff --git a/internal/swarmsearch/sync_wire_decode_msgtype_test.go b/internal/swarmsearch/sync_wire_decode_msgtype_test.go new file mode 100644 index 0000000..28cdebb --- /dev/null +++ b/internal/swarmsearch/sync_wire_decode_msgtype_test.go @@ -0,0 +1,26 @@ +package swarmsearch + +import ( + "testing" + + "github.com/anacrolix/torrent/bencode" +) + +// TestDecodeSyncBeginWrongMsgType covers DecodeSyncBegin's +// `if m.MsgType != MsgTypeSyncBegin` arm. Bencode a payload +// that decodes successfully into a SyncBegin struct but +// carries the wrong msg_type discriminator. +func TestDecodeSyncBeginWrongMsgType(t *testing.T) { + t.Parallel() + bad, err := bencode.Marshal(map[string]interface{}{ + "msg_type": int64(MsgTypeSyncEnd), // 8 — not Begin (4) + "tx_id": int64(1), + "element_size": int64(32), + }) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeSyncBegin(bad); err == nil { + t.Error("DecodeSyncBegin should reject wrong msg_type") + } +} From 04ae09a340c8e0d4fef39fc02ac92d0f2e285602 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 10:02:33 -0300 Subject: [PATCH 38/58] swarmsearch: cover DecodeSyncRecords too-many-records cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncodeSyncRecords enforces the same MaxRecordsPerMessage cap as DecodeSyncRecords, so the round-trip path can't exercise the decoder-side guard. Bencode-marshal the struct directly to bypass the publisher-side check, then verify DecodeSyncRecords catches a 501-record payload. DecodeSyncRecords 88.9% → 100%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sync_wire_decode_msgtype_test.go | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal/swarmsearch/sync_wire_decode_msgtype_test.go b/internal/swarmsearch/sync_wire_decode_msgtype_test.go index 28cdebb..2c2829a 100644 --- a/internal/swarmsearch/sync_wire_decode_msgtype_test.go +++ b/internal/swarmsearch/sync_wire_decode_msgtype_test.go @@ -6,6 +6,36 @@ import ( "github.com/anacrolix/torrent/bencode" ) +// TestDecodeSyncRecordsTooManyRecordsCap covers DecodeSyncRecords's +// `if len(m.Records) > MaxRecordsPerMessage` cap arm. Encode a +// SyncRecords frame with MaxRecordsPerMessage+1 records — the +// payload bencodes fine but the wire-level cap rejects it. +func TestDecodeSyncRecordsTooManyRecordsCap(t *testing.T) { + t.Parallel() + tooMany := make([]SyncRecord, MaxRecordsPerMessage+1) + for i := range tooMany { + tooMany[i] = SyncRecord{ + Pk: make([]byte, 32), + Ih: make([]byte, 20), + Sig: make([]byte, 64), + } + } + // EncodeSyncRecords also rejects oversize counts, so + // bencode-marshal the struct directly to bypass the + // publisher-side cap check. + raw, err := bencode.Marshal(SyncRecords{ + MsgType: MsgTypeSyncRecords, + TxID: 1, + Records: tooMany, + }) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeSyncRecords(raw); err == nil { + t.Error("DecodeSyncRecords should reject record count exceeding the cap") + } +} + // TestDecodeSyncBeginWrongMsgType covers DecodeSyncBegin's // `if m.MsgType != MsgTypeSyncBegin` arm. Bencode a payload // that decodes successfully into a SyncBegin struct but From ecfa3e88d2a4de6e54c1a69a996348b43240d05c Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 10:23:30 -0300 Subject: [PATCH 39/58] swarmsearch: cover EncodeSyncNeed nil-IDs default arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncodeSyncNeed substitutes an empty slice for a nil IDs slice before bencoding so the wire form always has an explicit (possibly empty) list rather than nothing. No existing test exercised that fall-through. Round trip a nil-IDs SyncNeed and verify the encoder + decoder both succeed. EncodeSyncNeed 88.9% → 100%; package 97.0% → 97.1%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sync_wire_decode_msgtype_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/swarmsearch/sync_wire_decode_msgtype_test.go b/internal/swarmsearch/sync_wire_decode_msgtype_test.go index 2c2829a..f68f5a4 100644 --- a/internal/swarmsearch/sync_wire_decode_msgtype_test.go +++ b/internal/swarmsearch/sync_wire_decode_msgtype_test.go @@ -6,6 +6,26 @@ import ( "github.com/anacrolix/torrent/bencode" ) +// TestEncodeSyncNeedNilIDsDefault covers EncodeSyncNeed's +// `if m.IDs == nil { m.IDs = [][]byte{} }` arm. Pass a nil +// IDs slice — the encoder must substitute the empty slice +// before bencoding so the on-the-wire form has an explicit +// (empty) list rather than nothing. +func TestEncodeSyncNeedNilIDsDefault(t *testing.T) { + t.Parallel() + raw, err := EncodeSyncNeed(SyncNeed{TxID: 1, IDs: nil}) + if err != nil { + t.Fatalf("EncodeSyncNeed nil IDs: %v", err) + } + if len(raw) == 0 { + t.Error("EncodeSyncNeed produced empty payload") + } + // Round trip back so we know the bencoded form is valid. + if _, err := DecodeSyncNeed(raw); err != nil { + t.Errorf("DecodeSyncNeed of nil-IDs round trip: %v", err) + } +} + // TestDecodeSyncRecordsTooManyRecordsCap covers DecodeSyncRecords's // `if len(m.Records) > MaxRecordsPerMessage` cap arm. Encode a // SyncRecords frame with MaxRecordsPerMessage+1 records — the From 0a72248a4272ab60815db71ed69f3e367f57b1b0 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 10:30:58 -0300 Subject: [PATCH 40/58] swarmsearch: cover NeedFrame too-many-IDs cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the session past Begin so the phase gate accepts, then pass MaxNeedIDsPerMessage+1 ids to trip the cap. NeedFrame 92.3% → 100%; package 97.1% → 97.2%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../swarmsearch/sync_session_branches_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/swarmsearch/sync_session_branches_test.go b/internal/swarmsearch/sync_session_branches_test.go index a4294bd..a64d055 100644 --- a/internal/swarmsearch/sync_session_branches_test.go +++ b/internal/swarmsearch/sync_session_branches_test.go @@ -4,6 +4,22 @@ import ( "testing" ) +// TestNeedFrameTooManyIDs covers NeedFrame's +// `if len(ids) > MaxNeedIDsPerMessage` arm. The session must +// be in PhaseBegun or PhaseSymbolsFlowing for the phase guard +// to pass; pass MaxNeedIDsPerMessage+1 ids to trip the cap. +func TestNeedFrameTooManyIDs(t *testing.T) { + t.Parallel() + s := NewSyncSession(1, RoleInitiator, nil) + if _, err := s.Begin(SyncFilter{}); err != nil { + t.Fatalf("Begin: %v", err) + } + tooMany := make([][32]byte, MaxNeedIDsPerMessage+1) + if _, err := s.NeedFrame(tooMany); err == nil { + t.Error("NeedFrame should reject IDs slice exceeding the cap") + } +} + // TestApplyNeedTxIDMismatch covers ApplyNeed's // `if m.TxID != s.txid` arm. func TestApplyNeedTxIDMismatch(t *testing.T) { From 24319ba474da9c007520ed5330cf86c4e76c4538 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 10:47:44 -0300 Subject: [PATCH 41/58] swarmsearch: cover Query all-sends-fail zero-asked arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plug in a sender that errors every Send call so the fan-out loop counts every error and asked stays at 0. Query must return an empty response without waiting for replies. Query 92.5% → 98.1%; package 97.2% → 97.3%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/swarmsearch/query_test.go | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/swarmsearch/query_test.go b/internal/swarmsearch/query_test.go index a2c9f10..ebb3945 100644 --- a/internal/swarmsearch/query_test.go +++ b/internal/swarmsearch/query_test.go @@ -129,6 +129,50 @@ func TestQueryNoCapablePeers(t *testing.T) { } } +// failingSender errors every Send call. Used to drive the +// "asked == 0" arm of Query when the fan-out's only target +// rejects synchronously. +type failingSender struct { + calls int + mu sync.Mutex +} + +func (f *failingSender) Send(_ string, _ []byte) error { + f.mu.Lock() + f.calls++ + f.mu.Unlock() + return errSendFailed +} + +var errSendFailed = errSimSend{} + +type errSimSend struct{} + +func (errSimSend) Error() string { return "simulated send failure" } + +// TestQueryAllSendsFailReturnsZeroAsked covers Query's +// `if asked == 0 { return ... }` arm. Mark a peer capable +// then plug in a sender that errors every call — the fan-out +// loop counts every error, asked stays at 0, and Query +// returns an empty response without waiting for replies. +func TestQueryAllSendsFailReturnsZeroAsked(t *testing.T) { + t.Parallel() + p := swarmsearch.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + p.SetSender(&failingSender{}) + markCapable(p, "1.2.3.4:6881") + + resp, err := p.Query(context.Background(), swarmsearch.QueryRequest{ + Q: "ubuntu", + Timeout: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("Query: %v", err) + } + if resp == nil || len(resp.Hits) != 0 { + t.Errorf("expected empty response, got %+v", resp) + } +} + func TestQueryEmptyRejected(t *testing.T) { t.Parallel() s := newFanoutSender() From 1f23e8bea63d30cf010eb2d299c13530f24ee5e1 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 11:01:31 -0300 Subject: [PATCH 42/58] swarmsearch: cover DecodePeerAnnounce truncation arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncodePeerAnnounce rejects oversized endorsement lists at publish time, so a round-trip test can only exercise the at-cap case. Hand-marshal a peer_announce payload with MaxEndorsedPerAnnounce+5 entries to bypass that check, then verify the decoder trims to the cap rather than blowing out memory or surfacing an error. DecodePeerAnnounce 92.3% → 100%; package 97.3% → 97.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/scheduled_tasks.lock | 1 + internal/swarmsearch/endorsement_test.go | 33 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..f379c2e --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"852a753e-eed4-478e-a059-1e220936837c","pid":2487097,"acquiredAt":1776864518141} \ No newline at end of file diff --git a/internal/swarmsearch/endorsement_test.go b/internal/swarmsearch/endorsement_test.go index d811260..0a17f65 100644 --- a/internal/swarmsearch/endorsement_test.go +++ b/internal/swarmsearch/endorsement_test.go @@ -3,6 +3,8 @@ package swarmsearch import ( "bytes" "testing" + + "github.com/anacrolix/torrent/bencode" ) // PeerAnnounce with endorsements round-trips through encode/decode @@ -66,6 +68,37 @@ func TestPeerAnnounceEndorsedDecodeFiltersMalformed(t *testing.T) { } } +// TestDecodePeerAnnounceTruncatesOverrun covers the +// `if len(pa.Endorsed) > MaxEndorsedPerAnnounce` truncation +// arm by hand-marshaling a payload that bypasses +// EncodePeerAnnounce's pre-publish cap check, then +// re-decoding. The decoder must trim the slice rather than +// blow out memory or surface an error. +func TestDecodePeerAnnounceTruncatesOverrun(t *testing.T) { + huge := make([][]byte, MaxEndorsedPerAnnounce+5) + for i := range huge { + e := make([]byte, 32) + e[0] = byte(i) + huge[i] = e + } + // Bencode-marshal directly to get past the publisher-side + // cap check. + raw, err := bencode.Marshal(PeerAnnounce{ + MsgType: MsgTypePeerAnnounce, + Endorsed: huge, + }) + if err != nil { + t.Fatal(err) + } + got, err := DecodePeerAnnounce(raw) + if err != nil { + t.Fatalf("DecodePeerAnnounce: %v", err) + } + if len(got.Endorsed) != MaxEndorsedPerAnnounce { + t.Errorf("decode len = %d, want %d (truncated)", len(got.Endorsed), MaxEndorsedPerAnnounce) + } +} + // Decode caps entries beyond MaxEndorsedPerAnnounce. func TestPeerAnnounceEndorsedDecodeTruncatesOverrun(t *testing.T) { huge := make([][]byte, MaxEndorsedPerAnnounce+5) From 512d8de4c4af89e1a686f16d5104bcc08d4acd18 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 11:02:07 -0300 Subject: [PATCH 43/58] gitignore: exclude local .claude/ workspace state The previous commit accidentally added .claude/scheduled_tasks.lock which is local workspace state. Untrack it and add .claude/ to .gitignore so future Claude Code sessions don't pick it up either. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/scheduled_tasks.lock | 1 - .gitignore | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index f379c2e..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"852a753e-eed4-478e-a059-1e220936837c","pid":2487097,"acquiredAt":1776864518141} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 90b1c5f..4b1a40d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ Thumbs.db /tests/torrent-test/peer*-data/ /tests/torrent-test/peer*-index/ /tests/torrent-test/results/ +.claude/ From 26ec681b6129bd317c33c1321f124e95cb21f155 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 11:06:03 -0300 Subject: [PATCH 44/58] swarmsearch: cover DecodeSyncSymbols + EncodeSyncRecords arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tests: - TestDecodeSyncSymbolsBadDataXORLen hand-marshals a frame whose first symbol carries a 5-byte DataXOR (must be 32); the post-decode validation loop rejects. - TestEncodeSyncRecordsNilRecordsDefault passes a nil Records slice through Encode + Decode to exercise the "substitute empty slice" default-fill arm. DecodeSyncSymbols 92.3% → 100%; EncodeSyncRecords 92.3% → 100%; package 97.4% → 97.6%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sync_wire_decode_msgtype_test.go | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/swarmsearch/sync_wire_decode_msgtype_test.go b/internal/swarmsearch/sync_wire_decode_msgtype_test.go index f68f5a4..5215df5 100644 --- a/internal/swarmsearch/sync_wire_decode_msgtype_test.go +++ b/internal/swarmsearch/sync_wire_decode_msgtype_test.go @@ -6,6 +6,47 @@ import ( "github.com/anacrolix/torrent/bencode" ) +// TestDecodeSyncSymbolsBadDataXORLen covers DecodeSyncSymbols' +// `if len(s.DataXOR) != 32` per-symbol validation. Hand- +// marshal a frame whose first symbol carries a 5-byte +// DataXOR — bencode round-trips fine but the post-decode +// loop rejects. +func TestDecodeSyncSymbolsBadDataXORLen(t *testing.T) { + t.Parallel() + bad, err := bencode.Marshal(SyncSymbols{ + MsgType: MsgTypeSyncSymbols, + TxID: 1, + Symbols: []SyncSymbol{ + {Count: 1, KeyXOR: 0xff, DataXOR: make([]byte, 5)}, + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeSyncSymbols(bad); err == nil { + t.Error("DecodeSyncSymbols should reject 5-byte DataXOR") + } +} + +// TestEncodeSyncRecordsNilRecordsDefault covers EncodeSyncRecords' +// `if m.Records == nil { m.Records = []SyncRecord{} }` arm. +// Pass a nil Records slice and verify the wire form is still +// produced (with the empty default substituted). +func TestEncodeSyncRecordsNilRecordsDefault(t *testing.T) { + t.Parallel() + raw, err := EncodeSyncRecords(SyncRecords{TxID: 1, Records: nil}) + if err != nil { + t.Fatalf("EncodeSyncRecords nil records: %v", err) + } + got, err := DecodeSyncRecords(raw) + if err != nil { + t.Fatalf("DecodeSyncRecords round trip: %v", err) + } + if len(got.Records) != 0 { + t.Errorf("got %d records, want 0", len(got.Records)) + } +} + // TestEncodeSyncNeedNilIDsDefault covers EncodeSyncNeed's // `if m.IDs == nil { m.IDs = [][]byte{} }` arm. Pass a nil // IDs slice — the encoder must substitute the empty slice From 8e52851f8c6c98e75890902bf6f41e2de1ba6070 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 11:09:06 -0300 Subject: [PATCH 45/58] swarmsearch: cover handleSyncFrame Records + End happy-path dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing tests covered every decode-error arm but not the happy-path forwards for SyncRecords and SyncEnd. Add direct tests that pass valid frames from a capability-marked peer and assert no misbehavior is charged. handleSyncFrame 94.1% → 100%; package 97.6% → 97.8%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../swarmsearch/handle_sync_frame_test.go | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/internal/swarmsearch/handle_sync_frame_test.go b/internal/swarmsearch/handle_sync_frame_test.go index 41f2f8f..13047e7 100644 --- a/internal/swarmsearch/handle_sync_frame_test.go +++ b/internal/swarmsearch/handle_sync_frame_test.go @@ -79,6 +79,51 @@ func TestHandleSyncFrameBadBencodeAcrossMsgTypes(t *testing.T) { } } +// TestHandleSyncFrameDispatchesRecords covers the +// `case MsgTypeSyncRecords: ... onSyncRecords()` happy-path +// dispatch. A capable peer sends a valid SyncRecords frame — +// no decode error, no misbehavior; the call is forwarded to +// onSyncRecords (which silently no-ops since no session is +// registered for the txid). +func TestHandleSyncFrameDispatchesRecords(t *testing.T) { + t.Parallel() + p := New(slog.Default()) + addr := "7.7.7.7:1" + p.mu.Lock() + p.peers[addr] = &PeerState{Services: BitSetReconciliation} + p.mu.Unlock() + raw, err := EncodeSyncRecords(SyncRecords{TxID: 42, Records: []SyncRecord{}}) + if err != nil { + t.Fatal(err) + } + reply := func([]byte) error { return nil } + p.handleSyncFrame(addr, messageHeader{MsgType: MsgTypeSyncRecords, TxID: 42}, raw, reply) + // No misbehavior since the frame decoded fine. + if got := p.MisbehaviorScore(addr); got != 0 { + t.Errorf("MisbehaviorScore = %d, want 0 for valid frame", got) + } +} + +// TestHandleSyncFrameDispatchesEnd covers the +// `case MsgTypeSyncEnd: ... onSyncEnd()` happy-path dispatch. +func TestHandleSyncFrameDispatchesEnd(t *testing.T) { + t.Parallel() + p := New(slog.Default()) + addr := "8.8.8.8:1" + p.mu.Lock() + p.peers[addr] = &PeerState{Services: BitSetReconciliation} + p.mu.Unlock() + raw, err := EncodeSyncEnd(SyncEnd{TxID: 42, Status: SyncStatusConverged}) + if err != nil { + t.Fatal(err) + } + reply := func([]byte) error { return nil } + p.handleSyncFrame(addr, messageHeader{MsgType: MsgTypeSyncEnd, TxID: 42}, raw, reply) + if got := p.MisbehaviorScore(addr); got != 0 { + t.Errorf("MisbehaviorScore = %d, want 0 for valid frame", got) + } +} + // TestHandleSyncFrameUnknownMsgType — an out-of-range msg_type // past the sync-handler dispatch must not panic. The dispatch // switch falls through to no-op (the outer HandleMessage layer From eb62af5f60142c94e70aa7eb7a0092388d1221a9 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 11:13:29 -0300 Subject: [PATCH 46/58] httpapi: cover handleSearch DHT-error arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dhtindex.Lookup.Query rejects queries that tokenise to nothing. Pass a stopword-only query "the of" — Bleve's local search handles it (returns no hits, no error) but Tokenize returns empty and Lookup.Query surfaces "no tokens". handleSearch must still respond 200 with the error string in the dht.error field. handleSearch 87.0% → 88.9%; package 97.9% → 98.2%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/httpapi/search_swarm_dht_test.go | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/internal/httpapi/search_swarm_dht_test.go b/internal/httpapi/search_swarm_dht_test.go index b40c548..0eabfaa 100644 --- a/internal/httpapi/search_swarm_dht_test.go +++ b/internal/httpapi/search_swarm_dht_test.go @@ -63,6 +63,46 @@ func TestHTTPSearchWithSwarmConfigured(t *testing.T) { } } +// TestHTTPSearchDHTQueryError covers handleSearch's +// `if err != nil { dhtResp.Error = err.Error() }` arm in the +// DHT path. dhtindex.Lookup.Query rejects queries that +// produce no tokens — passing an all-stopword query +// ("the of") makes Tokenize return empty and Lookup.Query +// surfaces the error. The HTTP layer must still respond 200 +// and put the error string in the dht.error field. +func TestHTTPSearchDHTQueryError(t *testing.T) { + t.Parallel() + // Need at least one indexer registered for Lookup.Query + // to even attempt tokenisation. + lookup := dhtindex.NewLookup(emptyDHTGetter{}) + idx := openTempIndex(t) + base := startServer(t, httpapi.Options{Index: idx, Lookup: lookup}) + + body, _ := json.Marshal(httpapi.SearchRequest{ + Q: "the of", // all-stopword → Tokenize returns empty + DHT: true, + DHTTimeoutMs: 50, + }) + resp, err := http.Post(base+"/search", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status=%d (expected 200 even on DHT-side error)", resp.StatusCode) + } + var got httpapi.SearchResponse + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if got.DHT == nil { + t.Fatal("DHT result should not be nil when Lookup is configured") + } + if got.DHT.Error == "" { + t.Errorf("expected DHT.Error to be populated, got %q", got.DHT.Error) + } +} + // TestHTTPSearchWithDHTConfigured covers the Lookup branch in // handleSearch. A Lookup wrapped around an emptyDHTGetter with no // indexers registered returns IndexersAsked=0 and an empty hits From f37b77e275842421ea162e9e6f747f65bb453058 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 11:15:50 -0300 Subject: [PATCH 47/58] httpapi: cover handleSearch DHT hit-iteration arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire a fixedHitGetter that returns a populated KeywordValue and register an indexer on the Lookup. Lookup.Query goes through its happy path with one hit, and handleSearch's `for _, h := range out.Hits { dhtResp.Hits = append(...) }` arm fires. handleSearch 88.9% → 90.7%; package 98.2% → 98.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/httpapi/search_swarm_dht_test.go | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/internal/httpapi/search_swarm_dht_test.go b/internal/httpapi/search_swarm_dht_test.go index 0eabfaa..2e87cac 100644 --- a/internal/httpapi/search_swarm_dht_test.go +++ b/internal/httpapi/search_swarm_dht_test.go @@ -63,6 +63,56 @@ func TestHTTPSearchWithSwarmConfigured(t *testing.T) { } } +// fixedHitGetter returns the same KeywordValue for every +// (pubkey, salt) pair. Used to drive Lookup.Query into its +// happy path so handleSearch's hit-iteration loop fires. +type fixedHitGetter struct { + hits []dhtindex.KeywordHit +} + +func (g fixedHitGetter) Get(_ context.Context, _ [32]byte, _ []byte) (dhtindex.KeywordValue, error) { + return dhtindex.KeywordValue{Hits: g.hits}, nil +} + +// TestHTTPSearchDHTReturnsHits covers handleSearch's +// `for _, h := range out.Hits { dhtResp.Hits = append(...) }` +// arm. Wire a Lookup with a fixedHitGetter that returns one +// hit, register an indexer, then post a query and verify the +// HTTP response carries the hit translated into a DHTHit. +func TestHTTPSearchDHTReturnsHits(t *testing.T) { + t.Parallel() + getter := fixedHitGetter{hits: []dhtindex.KeywordHit{ + {IH: bytes.Repeat([]byte{0xaa}, 20), N: "Ubuntu DHT", S: 100, F: 4, Sz: 6 << 30}, + }} + lookup := dhtindex.NewLookup(getter) + var pub [32]byte + pub[0] = 0xab + lookup.AddIndexer(pub, "indexer-one") + idx := openTempIndex(t) + base := startServer(t, httpapi.Options{Index: idx, Lookup: lookup}) + + body, _ := json.Marshal(httpapi.SearchRequest{ + Q: "ubuntu", + DHT: true, + DHTTimeoutMs: 200, + }) + resp, err := http.Post(base+"/search", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var got httpapi.SearchResponse + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if got.DHT == nil || len(got.DHT.Hits) == 0 { + t.Fatalf("expected DHT hits, got %+v", got.DHT) + } + if got.DHT.Hits[0].Name != "Ubuntu DHT" { + t.Errorf("hit Name = %q, want 'Ubuntu DHT'", got.DHT.Hits[0].Name) + } +} + // TestHTTPSearchDHTQueryError covers handleSearch's // `if err != nil { dhtResp.Error = err.Error() }` arm in the // DHT path. dhtindex.Lookup.Query rejects queries that From 1dba6e1eff12a199a318980f30daca79bcda6eef Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 11:21:53 -0300 Subject: [PATCH 48/58] engine: cover restoreEntry QueueOrder counter-bump arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session manifest entry with QueueOrder higher than the engine's nextQueueOrder counter must lift that counter so torrents added after restore get fresh slots above the restored set. restoreEntry 89.8% → 91.8%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/engine/restore_paused_entry_test.go | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/internal/engine/restore_paused_entry_test.go b/internal/engine/restore_paused_entry_test.go index 803822a..b5d9b43 100644 --- a/internal/engine/restore_paused_entry_test.go +++ b/internal/engine/restore_paused_entry_test.go @@ -13,6 +13,59 @@ import ( "github.com/swartznet/swartznet/internal/engine" ) +// TestRestoreSessionEntryQueueOrderBumpsCounter covers +// restoreEntry's `if entry.QueueOrder > e.nextQueueOrder` +// arm. A session manifest with a high QueueOrder must lift +// the engine's internal counter so subsequently-added +// torrents get fresh order numbers above the restored set. +func TestRestoreSessionEntryQueueOrderBumpsCounter(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + cfg := config.Default() + cfg.DataDir = dataDir + cfg.ListenPort = 0 + cfg.DisableDHT = true + cfg.NoUpload = true + cfg.IdentityPath = "" + cfg.ReputationPath = "" + cfg.SeedListPath = "" + cfg.BloomPath = "" + cfg.TrustPath = "" + + var ih [20]byte + if _, err := rand.Read(ih[:]); err != nil { + t.Fatal(err) + } + hexIH := hex.EncodeToString(ih[:]) + magnet := "magnet:?xt=urn:btih:" + hexIH + + writeSessionManifest(t, dataDir, []sessionEntryJSON{ + { + InfoHash: hexIH, + AddedVia: "magnet", + MagnetURI: magnet, + QueueOrder: 999, + Indexing: true, + }, + }) + + eng, err := engine.New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + defer eng.Close() + if err := eng.RestoreSession(); err != nil { + t.Fatalf("RestoreSession: %v", err) + } + + // Sanity: snapshot should reflect the restored torrent. + snaps := eng.TorrentSnapshots() + if len(snaps) != 1 { + t.Fatalf("len(snaps) = %d, want 1", len(snaps)) + } +} + // TestRestoreSessionPausedEntryDisablesData covers the // previously-uncovered entry.Paused branch in restoreEntry. // Hand-crafting a session manifest with a paused magnet entry From ad030609d9ac65d9722df216e359961d5f1ac09f Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 11:54:22 -0300 Subject: [PATCH 49/58] swarmsearch: cover ApplySymbols budget-exceeded arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the session past Begin so the phase guard accepts, then SetBudgets(2, 1024) and feed a 3-symbol frame so the `symbolsIn + len(Symbols) > maxSymbols` check fires. ApplySymbols 94.1% → 100%; package 97.7% → 97.8%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../swarmsearch/sync_session_branches_test.go | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/swarmsearch/sync_session_branches_test.go b/internal/swarmsearch/sync_session_branches_test.go index a64d055..05d1e0e 100644 --- a/internal/swarmsearch/sync_session_branches_test.go +++ b/internal/swarmsearch/sync_session_branches_test.go @@ -4,6 +4,31 @@ import ( "testing" ) +// TestApplySymbolsBudgetExceeded covers ApplySymbols' +// `if s.symbolsIn+len(m.Symbols) > s.maxSymbols` arm. Lower +// the budget via SetBudgets, advance to PhaseBegun, then +// feed a SyncSymbols frame with more symbols than the cap +// allows. +func TestApplySymbolsBudgetExceeded(t *testing.T) { + t.Parallel() + s := NewSyncSession(7, RoleInitiator, nil) + if _, err := s.Begin(SyncFilter{}); err != nil { + t.Fatalf("Begin: %v", err) + } + s.SetBudgets(2, 1024) + huge := SyncSymbols{ + TxID: 7, + Symbols: []SyncSymbol{ + {DataXOR: make([]byte, 32)}, + {DataXOR: make([]byte, 32)}, + {DataXOR: make([]byte, 32)}, // one over the budget + }, + } + if err := s.ApplySymbols(huge); err == nil { + t.Error("ApplySymbols should reject a frame that would exceed maxSymbols") + } +} + // TestNeedFrameTooManyIDs covers NeedFrame's // `if len(ids) > MaxNeedIDsPerMessage` arm. The session must // be in PhaseBegun or PhaseSymbolsFlowing for the phase guard From 278f1233736744ea4dbdc03549630f9dea5790fb Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 11:57:23 -0300 Subject: [PATCH 50/58] swarmsearch: cover ingestSyncRecords bad-length defence-in-depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire-level decoders (ApplyRecords + DecodeSyncRecords) already filter out records with wrong field lengths, but ingestSyncRecords is also called from cache-restore-style flows that may not go through the wire path. Bypass the wire layer by calling ingestSyncRecords directly with a 5-byte Pk so the per-record skip arm fires; sink + observer must both remain empty. ingestSyncRecords 95.5% → 100%; package 97.8% → 97.9%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../swarmsearch/publisher_observer_test.go | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/swarmsearch/publisher_observer_test.go b/internal/swarmsearch/publisher_observer_test.go index 3b97264..dd62061 100644 --- a/internal/swarmsearch/publisher_observer_test.go +++ b/internal/swarmsearch/publisher_observer_test.go @@ -127,6 +127,35 @@ func TestIngestSyncRecordsNotifiesPublisher(t *testing.T) { } } +// TestIngestSyncRecordsBadFieldLengthDefenceInDepth covers the +// defence-in-depth `if len(wr.Pk) != 32 || len(wr.Ih) != 20 || +// len(wr.Sig) != 64 { continue }` arm. The wire-level decoder +// already filters these (ApplyRecords + DecodeSyncRecords), but +// ingestSyncRecords is also called from cache-restore-style +// flows that don't go through the wire path. Pass a record +// with 5-byte Pk so the per-record loop skips it without +// invoking the sink. +func TestIngestSyncRecordsBadFieldLengthDefenceInDepth(t *testing.T) { + p := New(nil) + obs := &capturePublisherObserver{} + p.SetPublisherObserver(obs) + sink := NewRecordCache() + p.SetRecordSink(sink) + + // Bypass ApplyRecords / DecodeSyncRecords by calling + // ingestSyncRecords directly with a malformed record. + p.ingestSyncRecords("peer-bad", sink, []SyncRecord{ + {Pk: make([]byte, 5), Ih: make([]byte, 20), Sig: make([]byte, 64)}, + }) + + if obs.snapshot() != nil && len(obs.snapshot()) > 0 { + t.Errorf("observer saw a malformed record: %v", obs.snapshot()) + } + if sink.Len() != 0 { + t.Errorf("sink received malformed record: len=%d", sink.Len()) + } +} + // A record with a bad signature does NOT trigger NotePublisherSeen. // Signature is the filter; unsigned junk can't admit a pubkey // into the admission pipeline. From 488c04b48466a2a3a0c51bdb041cda60335da7a2 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 12:02:40 -0300 Subject: [PATCH 51/58] swarmsearch: cover OnRemoteHandshake publisher-announce arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a node has Capabilities.Publisher > 0 and a 32-byte publisher pubkey set via SetPublisherPubkey, the announce it sends after a peer handshake must: - flip BitLayerDPublisher in the services bitmap, and - carry the 32-byte pubkey in pa.Pubkey. A recordingHandshakeSender catches the goroutine-emitted PeerAnnounce; the test asserts both arms fire. OnRemoteHandshake 94.6% → 100%; package 97.9% → 98.1%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handshake_publisher_announce_test.go | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 internal/swarmsearch/handshake_publisher_announce_test.go diff --git a/internal/swarmsearch/handshake_publisher_announce_test.go b/internal/swarmsearch/handshake_publisher_announce_test.go new file mode 100644 index 0000000..988e63d --- /dev/null +++ b/internal/swarmsearch/handshake_publisher_announce_test.go @@ -0,0 +1,79 @@ +package swarmsearch + +import ( + "bytes" + "sync" + "testing" + "time" + + pp "github.com/anacrolix/torrent/peer_protocol" +) + +// recordingHandshakeSender captures the announce payload that +// OnRemoteHandshake's goroutine sends back. Used to assert +// that BitLayerDPublisher and the publisher pubkey are set +// when caps.Publisher > 0 + SetPublisherPubkey was called. +type recordingHandshakeSender struct { + mu sync.Mutex + payload []byte +} + +func (r *recordingHandshakeSender) Send(_ string, payload []byte) error { + r.mu.Lock() + r.payload = bytes.Clone(payload) + r.mu.Unlock() + return nil +} + +func (r *recordingHandshakeSender) snapshot() []byte { + r.mu.Lock() + defer r.mu.Unlock() + return bytes.Clone(r.payload) +} + +// TestOnRemoteHandshakePublisherCarriesPubkey covers the two +// arms of the announce-build path: +// - `if pubOn { services = services.With(BitLayerDPublisher) }` +// fires when caps.Publisher > 0. +// - `if pubOn && len(pubkey) == 32 { pa.Pubkey = ... }` +// fires when SetPublisherPubkey set a valid 32-byte key. +func TestOnRemoteHandshakePublisherCarriesPubkey(t *testing.T) { + p := New(nil) + p.SetCapabilities(Capabilities{ShareLocal: 1, Publisher: 1}) + pubkey := bytes.Repeat([]byte{0xab}, 32) + p.SetPublisherPubkey(pubkey) + rec := &recordingHandshakeSender{} + p.SetSender(rec) + + addr := "1.2.3.4:6881" + p.NotePeerAdded(addr) + p.OnRemoteHandshake(addr, &pp.ExtendedHandshakeMessage{ + M: map[pp.ExtensionName]pp.ExtensionNumber{ + ExtensionName: 11, + }, + }) + + // The announce is sent on a goroutine; poll briefly. + deadline := time.Now().Add(time.Second) + var raw []byte + for time.Now().Before(deadline) { + raw = rec.snapshot() + if raw != nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if raw == nil { + t.Fatal("no announce sent") + } + pa, err := DecodePeerAnnounce(raw) + if err != nil { + t.Fatalf("DecodePeerAnnounce: %v", err) + } + if ServiceBits(pa.Services)&BitLayerDPublisher == 0 { + t.Errorf("announce services missing BitLayerDPublisher: %x", pa.Services) + } + if !bytes.Equal(pa.Pubkey, pubkey) { + t.Errorf("announce pubkey = %x, want %x", pa.Pubkey, pubkey) + } +} From 199a2a82b048af6bb51bfd1e702487552b120de2 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 12:18:14 -0300 Subject: [PATCH 52/58] companion: cover EncodeLeaf EncodeRecord-error propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass a record with an empty keyword so the per-record EncodeRecord call inside EncodeLeaf's loop returns an error; EncodeLeaf must surface it rather than producing a leaf with a half-written payload. EncodeLeaf 88% → 92%; package 95.3% → 95.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/btree_encode_guards_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/companion/btree_encode_guards_test.go b/internal/companion/btree_encode_guards_test.go index 689eecd..7d0317d 100644 --- a/internal/companion/btree_encode_guards_test.go +++ b/internal/companion/btree_encode_guards_test.go @@ -49,6 +49,20 @@ func TestEncodeLeafEmptyRecords(t *testing.T) { } } +// TestEncodeLeafPropagatesEncodeRecordError covers EncodeLeaf's +// `if err != nil { return nil, err }` arm inside the per- +// record loop. Pass a record with an empty keyword so +// EncodeRecord errors immediately, and verify EncodeLeaf +// surfaces the error rather than producing a leaf with a +// half-written payload. +func TestEncodeLeafPropagatesEncodeRecordError(t *testing.T) { + t.Parallel() + bad := Record{Kw: ""} // EncodeRecord rejects empty keyword + if _, err := EncodeLeaf(0, []Record{bad}, MinPieceSize); err == nil { + t.Error("EncodeLeaf should propagate EncodeRecord errors") + } +} + // TestEncodeRecordOversizedKeyword — EncodeRecord rejects a // keyword longer than MaxKeywordBytes before attempting to // marshal. Same guard packLeaves uses, but exercised at the From 941dc2f4708c795d803480e612a1baafd9f11976 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 12:25:57 -0300 Subject: [PATCH 53/58] engine: cover sampleRate no-metadata early-return arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A magnet handle has no metadata until GotInfo fires; with DHT disabled and no peers, that never happens, so sampleRate must take the early return rather than call t.Stats() on a half- initialized torrent. sampleRate 90% → 93.3%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/engine/sample_rate_internal_test.go | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/internal/engine/sample_rate_internal_test.go b/internal/engine/sample_rate_internal_test.go index 5a2651d..f89523b 100644 --- a/internal/engine/sample_rate_internal_test.go +++ b/internal/engine/sample_rate_internal_test.go @@ -41,6 +41,41 @@ func minimalTorrentFile(t *testing.T, dir string) (string, string) { return path, metainfo.HashBytes(infoBytes).HexString() } +// TestSampleRateNoMetadataReturnsZeros covers sampleRate's +// `if h.T.Info() == nil { return 0, 0 }` early-return arm. +// A magnet handle has no metadata until GotInfo fires; with +// DHT disabled and no peers, that never happens, so +// sampleRate must take the early return. +func TestSampleRateNoMetadataReturnsZeros(t *testing.T) { + t.Parallel() + cfg := config.Default() + cfg.DataDir = t.TempDir() + cfg.ListenPort = 0 + cfg.DisableDHT = true + cfg.NoUpload = true + cfg.IdentityPath = "" + cfg.ReputationPath = "" + cfg.SeedListPath = "" + cfg.BloomPath = "" + cfg.TrustPath = "" + + eng, err := New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + defer eng.Close() + + const magnet = "magnet:?xt=urn:btih:1111111111111111111111111111111111111111" + h, err := eng.AddMagnet(magnet) + if err != nil { + t.Fatalf("AddMagnet: %v", err) + } + dr, ur := h.sampleRate() + if dr != 0 || ur != 0 { + t.Errorf("magnet without metadata sampleRate = (%d, %d), want (0, 0)", dr, ur) + } +} + // TestSampleRateFirstCallSeedsZeros pins the documented first- // call behaviour: sampleRate seeds its internal state and // returns (0, 0). From 3cee59051ae3b756ba2653415f97f3f77ae89979 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 12:34:47 -0300 Subject: [PATCH 54/58] engine: cover restoreEntry closed-engine early-return arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the engine then call restoreEntry directly (inside- package test) so the `if e.closed { return error }` guard fires before any torrent.Client state is touched. restoreEntry 91.8% → 95.9%; engine package 84.5% → 84.8%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/engine/restore_entry_closed_test.go | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 internal/engine/restore_entry_closed_test.go diff --git a/internal/engine/restore_entry_closed_test.go b/internal/engine/restore_entry_closed_test.go new file mode 100644 index 0000000..2289f6c --- /dev/null +++ b/internal/engine/restore_entry_closed_test.go @@ -0,0 +1,49 @@ +package engine + +import ( + "context" + "io" + "log/slog" + "testing" + + "github.com/swartznet/swartznet/internal/config" +) + +// TestRestoreEntryAfterCloseFails covers restoreEntry's +// `if e.closed { return error }` early-return arm. Close the +// engine, then call restoreEntry directly (we're inside the +// package so e.closed is reachable). Must surface +// "engine: closed" rather than crash on a half-initialized +// torrent client. +func TestRestoreEntryAfterCloseFails(t *testing.T) { + t.Parallel() + cfg := config.Default() + cfg.DataDir = t.TempDir() + cfg.ListenPort = 0 + cfg.DisableDHT = true + cfg.NoUpload = true + cfg.IdentityPath = "" + cfg.ReputationPath = "" + cfg.SeedListPath = "" + cfg.BloomPath = "" + cfg.TrustPath = "" + + eng, err := New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + if err := eng.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + // Engine is now closed. restoreEntry must return "closed" + // on the very first thing it does, before touching any + // torrent.Client state. + err = eng.restoreEntry(sessionEntry{ + InfoHash: "0000000000000000000000000000000000000000", + AddedVia: "magnet", + MagnetURI: "magnet:?xt=urn:btih:0000000000000000000000000000000000000000", + }) + if err == nil { + t.Error("restoreEntry on closed engine should error") + } +} From 3e0822a11ca2b8e654a8c5947304e4fcb07e28ab Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 12:40:08 -0300 Subject: [PATCH 55/58] engine: cover loadSession mkdir + read error arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tests: - TestLoadSessionMkdirFails plants a regular file at /torrents so MkdirAll can't make it a directory. - TestLoadSessionReadFileFails plants a directory at session.json so os.ReadFile fails with EISDIR (a non- ErrNotExist error that the existing NoFileYetReturnsEmpty test deliberately doesn't cover). loadSession 90.9% → 100%; engine package 84.5% → 84.9%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/engine/session_loadsession_test.go | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/internal/engine/session_loadsession_test.go b/internal/engine/session_loadsession_test.go index 594b06d..9211f1c 100644 --- a/internal/engine/session_loadsession_test.go +++ b/internal/engine/session_loadsession_test.go @@ -57,6 +57,51 @@ func TestLoadSessionEmptyFileReturnsEmpty(t *testing.T) { } } +// TestLoadSessionMkdirFails covers loadSession's +// `os.MkdirAll(s.torrentsDir, ...) error` arm. Plant a +// regular file at /torrents so MkdirAll can't make +// the path a directory; the wrapped "engine: mkdir torrents" +// error must surface. +func TestLoadSessionMkdirFails(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Pre-plant a regular file at the path that's about to be + // created as a directory. + if err := os.WriteFile(filepath.Join(dir, "torrents"), []byte{}, 0o600); err != nil { + t.Fatal(err) + } + _, err := loadSession(dir) + if err == nil { + t.Fatal("expected MkdirAll error when torrents path is a regular file") + } + if !strings.Contains(err.Error(), "mkdir torrents") { + t.Errorf("err = %q, want it to mention 'mkdir torrents'", err.Error()) + } +} + +// TestLoadSessionReadFileFails covers loadSession's +// `os.ReadFile error not ErrNotExist` arm. Plant a directory +// at session.json so os.ReadFile fails with EISDIR (Linux). +func TestLoadSessionReadFileFails(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "torrents"), 0o755); err != nil { + t.Fatal(err) + } + // Plant a directory where session.json is expected so + // os.ReadFile returns EISDIR (not ErrNotExist). + if err := os.MkdirAll(filepath.Join(dir, "session.json"), 0o755); err != nil { + t.Fatal(err) + } + _, err := loadSession(dir) + if err == nil { + t.Fatal("expected read error when session.json is a directory") + } + if !strings.Contains(err.Error(), "read session") { + t.Errorf("err = %q, want it to wrap 'read session'", err.Error()) + } +} + // TestLoadSessionGarbageJSONErrors covers the json.Unmarshal // error branch: garbage bytes in session.json must return a // wrapped "decode session" error rather than silently dropping From 9537c303ab5fe3fb29a279296c97f9581105ec8c Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 13:13:59 -0300 Subject: [PATCH 56/58] engine: cover Close bloom + tracker persistence arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Construct an engine with BloomPath and ReputationPath set so the spam-resistance subsystems are wired in. Close then runs the `if e.bloom != nil { e.bloom.Save() }` and matching tracker save arms; verify both files exist on disk afterwards. Engine.Close 80% → 84%; package 84.9% → 85.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/engine/close_persists_test.go | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 internal/engine/close_persists_test.go diff --git a/internal/engine/close_persists_test.go b/internal/engine/close_persists_test.go new file mode 100644 index 0000000..80873e6 --- /dev/null +++ b/internal/engine/close_persists_test.go @@ -0,0 +1,51 @@ +package engine_test + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/swartznet/swartznet/internal/config" + "github.com/swartznet/swartznet/internal/engine" +) + +// TestEngineClosePersistsBloomAndTracker covers the +// `if e.bloom != nil { e.bloom.Save() }` and matching tracker +// arms of Close. Construct an engine with valid BloomPath + +// ReputationPath, close it, then verify the files are present +// on disk afterwards. +func TestEngineClosePersistsBloomAndTracker(t *testing.T) { + t.Parallel() + dataDir := t.TempDir() + bloomPath := filepath.Join(dataDir, "bloom.bin") + repPath := filepath.Join(dataDir, "rep.json") + + cfg := config.Default() + cfg.DataDir = dataDir + cfg.ListenPort = 0 + cfg.DisableDHT = true + cfg.NoUpload = true + cfg.IdentityPath = "" + cfg.SeedListPath = "" + cfg.TrustPath = "" + // BloomPath + ReputationPath set so engine.New wires them. + cfg.BloomPath = bloomPath + cfg.ReputationPath = repPath + + eng, err := engine.New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + if err := eng.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := os.Stat(bloomPath); err != nil { + t.Errorf("bloom file missing after Close: %v", err) + } + if _, err := os.Stat(repPath); err != nil { + t.Errorf("reputation file missing after Close: %v", err) + } +} From d729c63ac69bd6c9dec1a9c8da553858ef76f7eb Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 13:23:28 -0300 Subject: [PATCH 57/58] companion: cover DecodeTrailer kind + payload-length arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tests: - TestDecodeTrailerBadKind: hand-build a leaf-kind page and pass it to DecodeTrailer; the kind guard rejects. - TestDecodeTrailerWrongPayloadLength: build a trailer-kind page whose header claims a 32-byte payload (not the fixed TrailerPayloadSize); the size guard rejects before any body byte is read. DecodeTrailer 93.8% → 100%; package 95.4% → 95.7%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/decode_leaf_errors_test.go | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/companion/decode_leaf_errors_test.go b/internal/companion/decode_leaf_errors_test.go index 9be6b61..6445639 100644 --- a/internal/companion/decode_leaf_errors_test.go +++ b/internal/companion/decode_leaf_errors_test.go @@ -94,6 +94,38 @@ func TestEncodeTrailerBadVersion(t *testing.T) { } } +// TestDecodeTrailerBadKind — DecodeTrailer must reject a +// page whose header decodes successfully but whose Kind is +// not PageKindTrailer. +func TestDecodeTrailerBadKind(t *testing.T) { + t.Parallel() + body := make([]byte, TrailerPayloadSize) + page := makeInteriorPage(t, PageKindLeaf, body, MinPieceSize) + if _, err := DecodeTrailer(page); err == nil { + t.Error("DecodeTrailer should reject leaf-kind page") + } +} + +// TestDecodeTrailerWrongPayloadLength — a trailer whose +// header.PayloadLength differs from TrailerPayloadSize must be +// rejected before any body bytes are read. +func TestDecodeTrailerWrongPayloadLength(t *testing.T) { + t.Parallel() + // Hand-build a page with PageKindTrailer but wrong payload + // length. encodeHeader clamps to uint16; pass a length + // smaller than TrailerPayloadSize. + page := make([]byte, MinPieceSize) + hdr := encodeHeader(PageHeader{ + Version: BTreeVersion, + Kind: PageKindTrailer, + PayloadLength: 32, // not TrailerPayloadSize + }) + copy(page, hdr) + if _, err := DecodeTrailer(page); err == nil { + t.Error("DecodeTrailer should reject mismatched PayloadLength") + } +} + // TestDecodeTrailerBadVersion — DecodeTrailer must reject a // trailer whose first payload byte (TrailerVersion) is not // 0x01. Hand-build the page since EncodeTrailer rejects bad From 014f42ac2bae5359c8dbb7deb6776a2d0b6c9456 Mon Sep 17 00:00:00 2001 From: Frank Dosk Date: Sun, 26 Apr 2026 13:25:42 -0300 Subject: [PATCH 58/58] companion: cover BuildBTree packLeaves-error propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass a record with empty keyword so packLeaves rejects; BuildBTree must propagate the error rather than continue with a half-built tree. BuildBTree 93.4% → 94.5%; package 95.7% → 95.8%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/companion/build_btree_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/companion/build_btree_test.go b/internal/companion/build_btree_test.go index a772258..cf9c7f7 100644 --- a/internal/companion/build_btree_test.go +++ b/internal/companion/build_btree_test.go @@ -235,6 +235,27 @@ func TestBuildBTreeMultiLeafWalk(t *testing.T) { } } +// TestBuildBTreePropagatesPackLeavesError covers BuildBTree's +// `if err != nil { return out, err }` arm after packLeaves. +// Pass a record with empty keyword so packLeaves rejects; +// BuildBTree must surface that error. +func TestBuildBTreePropagatesPackLeavesError(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + var pk [32]byte + copy(pk[:], pub) + bad := Record{Kw: ""} // packLeaves rejects empty keyword + if _, err := BuildBTree(BuildBTreeInput{ + Records: []Record{bad}, + PubKey: pk, + PrivKey: priv, + Seq: 1, + PieceSize: MinPieceSize, + CreatedTs: 1, + }); err == nil { + t.Error("BuildBTree should propagate packLeaves errors") + } +} + func TestBuildBTreeRejectsEmpty(t *testing.T) { pub, priv, _ := ed25519.GenerateKey(rand.Reader) var pk [32]byte