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/
diff --git a/internal/companion/btree_encode_guards_test.go b/internal/companion/btree_encode_guards_test.go
index db2cb3a..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
@@ -61,3 +75,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")
+ }
+}
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
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
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")
+ }
+}
diff --git a/internal/companion/pow_test.go b/internal/companion/pow_test.go
index 4534e59..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
@@ -100,6 +117,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.
diff --git a/internal/companion/read_btree_test.go b/internal/companion/read_btree_test.go
index e5db433..9e6bf0f 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,315 @@ 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 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 {
+ 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() }
+
+// 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
+// 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
+// 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
+// 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
+// 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))
+ }
+}
+
+// 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
+// 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.
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
diff --git a/internal/daemon/bootstrap_https_adapter_test.go b/internal/daemon/bootstrap_https_adapter_test.go
index afd0423..225b90b 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) {
@@ -129,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
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)
diff --git a/internal/dhtindex/dht_pointer_validation_test.go b/internal/dhtindex/dht_pointer_validation_test.go
index 27f3a1f..feef591 100644
--- a/internal/dhtindex/dht_pointer_validation_test.go
+++ b/internal/dhtindex/dht_pointer_validation_test.go
@@ -54,6 +54,121 @@ 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)
+// 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) {
diff --git a/internal/dhtindex/lookup_test.go b/internal/dhtindex/lookup_test.go
index 98f967c..74e7956 100644
--- a/internal/dhtindex/lookup_test.go
+++ b/internal/dhtindex/lookup_test.go
@@ -172,6 +172,77 @@ 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
+// legacyQuery's merge loop. A single indexer returns two hits
+// 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, Sz: 0, F: 0},
+ {IH: bytes.Repeat([]byte{0xaa}, 20), N: "Ubuntu Late", S: 100, Sz: 6 << 30, F: 12},
+ }})
+
+ 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))
+ }
+ 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)
+ }
+}
+
func TestLookupQueryEmptyTokens(t *testing.T) {
t.Parallel()
l := dhtindex.NewLookup(newScriptedGetter())
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
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)
+ }
+}
diff --git a/internal/engine/create_errors_test.go b/internal/engine/create_errors_test.go
index 7c43bbb..beb80c1 100644
--- a/internal/engine/create_errors_test.go
+++ b/internal/engine/create_errors_test.go
@@ -41,6 +41,53 @@ 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
+// 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
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")
+ }
+}
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
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).
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
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
+}
diff --git a/internal/httpapi/search_swarm_dht_test.go b/internal/httpapi/search_swarm_dht_test.go
index b40c548..2e87cac 100644
--- a/internal/httpapi/search_swarm_dht_test.go
+++ b/internal/httpapi/search_swarm_dht_test.go
@@ -63,6 +63,96 @@ 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
+// 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
diff --git a/internal/indexer/pipeline_handle_branches_test.go b/internal/indexer/pipeline_handle_branches_test.go
index 6487b89..680a04d 100644
--- a/internal/indexer/pipeline_handle_branches_test.go
+++ b/internal/indexer/pipeline_handle_branches_test.go
@@ -96,6 +96,87 @@ 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)
+ }
+}
+
+// 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.
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)
+ }
+}
diff --git a/internal/reputation/write_bloom_internal_test.go b/internal/reputation/write_bloom_internal_test.go
new file mode 100644
index 0000000..bdd50e5
--- /dev/null
+++ b/internal/reputation/write_bloom_internal_test.go
@@ -0,0 +1,117 @@
+package reputation
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "math"
+ "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")
+ }
+}
+
+// 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
+// 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")
+ }
+}
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)
diff --git a/internal/swarmsearch/handle_query_test.go b/internal/swarmsearch/handle_query_test.go
index f13f721..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,48 @@ 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
+// 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.
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
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
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)
+ }
+}
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.
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()
diff --git a/internal/swarmsearch/sync_session_branches_test.go b/internal/swarmsearch/sync_session_branches_test.go
index 1cf0e15..05d1e0e 100644
--- a/internal/swarmsearch/sync_session_branches_test.go
+++ b/internal/swarmsearch/sync_session_branches_test.go
@@ -4,6 +4,113 @@ 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
+// 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) {
+ 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
+// 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) {
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..5215df5
--- /dev/null
+++ b/internal/swarmsearch/sync_wire_decode_msgtype_test.go
@@ -0,0 +1,117 @@
+package swarmsearch
+
+import (
+ "testing"
+
+ "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
+// 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
+// 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
+// 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")
+ }
+}