From 772d1ce6d7713cb2bdf61fdf85879a30458dbd54 Mon Sep 17 00:00:00 2001 From: rene <41963722+renaynay@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:43:34 +0200 Subject: [PATCH] fix(p2p/server): reject range requests below the store tail A range request whose `from` is below the store's tail can only be served as a hard NOT_FOUND, since a partial range must start at `from`. Check the tail up front and return ErrNotFound instead of walking into pruned heights. Resolves PROTOCO-2383. Complements #405. Co-Authored-By: Claude Opus 4.8 --- p2p/server.go | 17 +++++++++++++++++ p2p/server_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/p2p/server.go b/p2p/server.go index 8a5cdaf2..ad1e4479 100644 --- a/p2p/server.go +++ b/p2p/server.go @@ -231,6 +231,23 @@ func (serv *ExchangeServer[H]) handleRangeRequest( } log.Debugw("server: handling headers request", "from", from, "to", to) + + // reject requests whose bottom is below the tail: those headers were + // pruned, so no contiguous range starting at `from` can be served. + tail, err := serv.store.Tail(ctx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + log.Debugw("server: could not get current tail", "err", err) + serv.metrics.rangeServed(ctx, time.Since(startTime), to-from, true) + return nil, err + } + if from < tail.Height() { + span.SetStatus(codes.Error, header.ErrNotFound.Error()) + log.Debugw("server: requested headers below tail", "from", from, "tail", tail.Height()) + serv.metrics.rangeServed(ctx, time.Since(startTime), to-from, true) + return nil, header.ErrNotFound + } + // check that store has the requested height if !serv.store.HasAt(ctx, to-1) { head, err := serv.store.Head(ctx) diff --git a/p2p/server_test.go b/p2p/server_test.go index 315359fc..d18f078a 100644 --- a/p2p/server_test.go +++ b/p2p/server_test.go @@ -58,6 +58,53 @@ func TestExchangeServer_errorsOnLargeRequest(t *testing.T) { require.Error(t, err) } +// TestExchangeServer_rangeBelowTail ensures a request whose `from` is below the +// store's tail (pruned) is rejected with ErrNotFound before reaching GetRange, +// rather than expanding `to` up to the head and walking a huge range. +func TestExchangeServer_rangeBelowTail(t *testing.T) { + peer := createMocknet(t, 1) + + tail := headertest.RandDummyHeader(t) + tail.HeightI = 9_000_000 + head := headertest.RandDummyHeader(t) + head.HeightI = 10_000_000 + store := &belowTailStore[*headertest.DummyHeader]{tail: tail, head: head} + + server, err := NewExchangeServer[*headertest.DummyHeader]( + peer[0], + store, + WithNetworkID[ServerParameters](networkID), + ) + require.NoError(t, err) + + // request a low range below the tail (pruned) + _, err = server.handleRangeRequest(context.Background(), 1, 2) + require.ErrorIs(t, err, header.ErrNotFound) + require.False(t, store.getRangeCalled, "GetRange must not be called for a below-tail request") +} + +// belowTailStore is a minimal store with a tail and head far ahead. It records +// whether GetRange was called so the test can assert the request is rejected early. +type belowTailStore[H header.Header[H]] struct { + header.Store[H] + tail H + head H + getRangeCalled bool +} + +func (s *belowTailStore[H]) Tail(context.Context) (H, error) { return s.tail, nil } + +func (s *belowTailStore[H]) Head(context.Context, ...header.HeadOption[H]) (H, error) { + return s.head, nil +} + +func (s *belowTailStore[H]) HasAt(context.Context, uint64) bool { return false } + +func (s *belowTailStore[H]) GetRange(context.Context, uint64, uint64) ([]H, error) { + s.getRangeCalled = true + return nil, nil +} + func TestExchangeServer_Timeout(t *testing.T) { const testRequestTimeout = 150 * time.Millisecond