Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions app/controlplane/internal/service/referrer.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,28 @@ func (s *ReferrerService) DiscoverPublicShared(ctx context.Context, req *pb.Disc
}, nil
}

// defaultReferrerPageSize is the page size applied when a referrer Discover* request
// arrives without pagination. It deliberately overrides the package-wide
// pagination.DefaultCursorLimit (10) because referrer responses render nested references
// (SBOMs, SARIF, …) and a slightly larger page is easier to navigate without being
// noticeably slower. Keep ≤ the proto-enforced max of 100.
const defaultReferrerPageSize = 20

// referrerPaginationOptsFromProto converts the proto pagination request to cursor options.
// Returns nil when the request has no pagination, preserving backward compatibility (all references returned).
// When the request has no pagination or an unset limit, the default page size is applied
// so that the response is always bounded. A root referrer (e.g. a container image) can
// accumulate an unbounded number of direct references (SBOMs, SARIF reports, ...) as it
// is attested repeatedly — returning all of them in a single response is unsafe.
func referrerPaginationOptsFromProto(p *pb.CursorPaginationRequest) (*pagination.CursorOptions, error) {
if p == nil {
return nil, nil
}
limit := int(p.GetLimit())
if limit == 0 {
limit = defaultReferrerPageSize
limit := defaultReferrerPageSize
var cursor string
if p != nil {
cursor = p.GetCursor()
if l := int(p.GetLimit()); l > 0 {
limit = l
}
}
return pagination.NewCursor(p.GetCursor(), limit)
return pagination.NewCursor(cursor, limit)
}

func bizReferrerToPb(r *biz.StoredReferrer) *pb.ReferrerItem {
Expand Down
81 changes: 81 additions & 0 deletions app/controlplane/internal/service/referrer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package service

import (
"testing"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestReferrerPaginationOptsFromProto(t *testing.T) {
t.Parallel()

tests := []struct {
name string
in *pb.CursorPaginationRequest
wantLimit int
}{
{
name: "nil request applies default page size",
in: nil,
wantLimit: defaultReferrerPageSize,
},
{
name: "empty request applies default page size",
in: &pb.CursorPaginationRequest{},
wantLimit: defaultReferrerPageSize,
},
{
name: "zero limit applies default page size",
in: &pb.CursorPaginationRequest{Limit: 0},
wantLimit: defaultReferrerPageSize,
},
{
name: "explicit limit is honored",
in: &pb.CursorPaginationRequest{Limit: 50},
wantLimit: 50,
},
{
name: "limit of 1 is honored",
in: &pb.CursorPaginationRequest{Limit: 1},
wantLimit: 1,
},
{
name: "negative limit falls through to default page size",
in: &pb.CursorPaginationRequest{Limit: -5},
wantLimit: defaultReferrerPageSize,
},
{
name: "proto max limit of 100 is honored",
in: &pb.CursorPaginationRequest{Limit: 100},
wantLimit: 100,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

opts, err := referrerPaginationOptsFromProto(tc.in)
require.NoError(t, err)
require.NotNil(t, opts, "pagination options must always be returned so the response is bounded")
assert.Equal(t, tc.wantLimit, opts.Limit)
})
}
}
34 changes: 19 additions & 15 deletions app/controlplane/pkg/data/referrer.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,23 +238,27 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg
// Attach the workflow predicate
predicateReferrer = append(predicateReferrer, referrer.HasWorkflowsWith(predicateWF...))

// Defense-in-depth: if the caller did not supply pagination options, fall back
// to the package-wide default instead of emitting an unbounded query. This
// guarantees the response is bounded even when a future biz-layer caller
// forgets to pass options through — see chainloop-dev/chainloop#2890.
if p == nil {
p = &pagination.CursorOptions{Limit: pagination.DefaultCursorLimit}
}

// Sort references by creation date and ID in descending order for deterministic pagination
q := root.QueryReferences().Where(predicateReferrer...).WithWorkflows().
Order(referrer.ByCreatedAt(sql.OrderDesc())).
Order(referrer.ByID(sql.OrderDesc()))

// Apply pagination: fetch limit+1 to detect next page
if p != nil {
q = q.Limit(p.Limit + 1)

if p.Cursor != nil {
q = q.Where(func(s *sql.Selector) {
s.Where(sql.CompositeLT(
[]string{s.C(referrer.FieldCreatedAt), s.C(referrer.FieldID)},
p.Cursor.Timestamp, p.Cursor.ID,
))
})
}
Order(referrer.ByID(sql.OrderDesc())).
Limit(p.Limit + 1) // fetch limit+1 to detect next page

if p.Cursor != nil {
q = q.Where(func(s *sql.Selector) {
s.Where(sql.CompositeLT(
[]string{s.C(referrer.FieldCreatedAt), s.C(referrer.FieldID)},
p.Cursor.Timestamp, p.Cursor.ID,
))
})
}

refs, err := q.All(ctx)
Expand All @@ -264,7 +268,7 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg

// Determine if there is a next page and encode the cursor
var nextCursor string
if p != nil && len(refs) > p.Limit {
if len(refs) > p.Limit {
lastVisible := refs[p.Limit-1]
nextCursor = pagination.EncodeCursor(lastVisible.CreatedAt, lastVisible.ID)
refs = refs[:p.Limit]
Expand Down
Loading