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") + } +}