Skip to content
Merged
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
71 changes: 71 additions & 0 deletions bindings/go/osvdev/osvdev.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,15 @@ const (
// DetermineVersionEndpoint is the URL for posting determineversion queries to OSV.
DetermineVersionEndpoint = "/v1experimental/determineversion"

// UbuntuBinaryToSourceEndpoint is the URL for posting Ubuntu binary-to-source package mapping queries to OSV.
UbuntuBinaryToSourceEndpoint = "/v1experimental/ubuntu/binary-to-source"

// MaxQueriesPerQueryBatchRequest is a limit set in osv.dev's API, so is not configurable
MaxQueriesPerQueryBatchRequest = 1000

// MaxPackagesPerUbuntuMappingRequest is a limit set in osv.dev's API, so is not configurable
MaxPackagesPerUbuntuMappingRequest = 1000

DefaultBaseURL = "https://api.osv.dev"
)

Expand Down Expand Up @@ -183,6 +189,71 @@ func (c *OSVClient) ExperimentalDetermineVersion(ctx context.Context, query *api
return &result, nil
}

// ExperimentalQueryUbuntuPackageMapping is an interface to the experimental
// /v1experimental/ubuntu/binary-to-source endpoint:
// https://google.github.io/osv.dev/post-v1-ubuntu-binary-to-source/
//
// If more than MaxPackagesPerUbuntuMappingRequest binary names are provided,
// requests are automatically chunked and executed concurrently.
func (c *OSVClient) ExperimentalQueryUbuntuPackageMapping(
ctx context.Context,
params *api.UbuntuPackageMappingParameters,
) (*api.UbuntuPackageMappingResponse, error) {
if params == nil {
return nil, errors.New("params cannot be nil")
}

if len(params.GetBinaryNames()) <= MaxPackagesPerUbuntuMappingRequest {
var result api.UbuntuPackageMappingResponse
if err := c.makeRequest(ctx, UbuntuBinaryToSourceEndpoint, params, &result); err != nil {
return nil, err
}

return &result, nil
}

nameChunks := chunkBy(params.GetBinaryNames(), MaxPackagesPerUbuntuMappingRequest)
batchedResults := make([][]*api.SourcePackages, len(nameChunks))

g, errGrpCtx := errgroup.WithContext(ctx)
if c.Config.MaxConcurrentBatchRequests > 0 {
g.SetLimit(c.Config.MaxConcurrentBatchRequests)
}
for batchIndex, chunk := range nameChunks {
g.Go(func() error {
if errGrpCtx.Err() != nil {
return errGrpCtx.Err()
}

var chunkResp api.UbuntuPackageMappingResponse
err := c.makeRequest(errGrpCtx, UbuntuBinaryToSourceEndpoint, &api.UbuntuPackageMappingParameters{
Ecosystem: params.GetEcosystem(),
BinaryNames: chunk,
}, &chunkResp)
if err != nil {
return err
}

batchedResults[batchIndex] = chunkResp.GetResults()

return nil
})
}

if err := g.Wait(); err != nil {
return nil, err
}

totalResp := &api.UbuntuPackageMappingResponse{
Results: make([]*api.SourcePackages, 0, len(params.GetBinaryNames())),
}
for _, batch := range batchedResults {
totalResp.Results = append(totalResp.Results, batch...)
}

return totalResp, nil
}

func (c *OSVClient) makeRequest(ctx context.Context, endpoint string, reqBody, resBody proto.Message) error {
requestBytes, err := protojson.Marshal(reqBody)
if err != nil {
Expand Down
167 changes: 167 additions & 0 deletions bindings/go/osvdev/osvdev_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ package osvdev_test

import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/ossf/osv-schema/bindings/go/osvconstants"
"github.com/ossf/osv-schema/bindings/go/osvschema"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/testing/protocmp"
"osv.dev/bindings/go/api"
"osv.dev/bindings/go/internal/testhelper"
"osv.dev/bindings/go/osvdev"
Expand Down Expand Up @@ -445,3 +452,163 @@ func TestOSVClient_ExperimentalDetermineVersion(t *testing.T) {
})
}
}

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

var chunkedRequests atomic.Int32

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != osvdev.UbuntuBinaryToSourceEndpoint {
http.Error(w, "unexpected path", http.StatusNotFound)
return
}
if r.Method != http.MethodPost {
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
return
}

body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

var params api.UbuntuPackageMappingParameters
if err := protojson.Unmarshal(body, &params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

if params.GetEcosystem() == "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":3,"message":"ecosystem is required"}`))

return
}
if len(params.GetBinaryNames()) > osvdev.MaxPackagesPerUbuntuMappingRequest {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":3,"message":"too many binary_names"}`))

return
}
if params.GetEcosystem() == "Ubuntu:22.04" {
chunkedRequests.Add(1)
}

resp := &api.UbuntuPackageMappingResponse{
Results: make([]*api.SourcePackages, len(params.GetBinaryNames())),
}
for i, bin := range params.GetBinaryNames() {
if bin == "unknown" {
resp.Results[i] = &api.SourcePackages{}
} else {
resp.Results[i] = &api.SourcePackages{
SourceNames: []string{"src-" + bin},
}
}
}

out, err := protojson.Marshal(resp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(out)
}))
t.Cleanup(server.Close)

t.Run("single batch request", func(t *testing.T) {
t.Parallel()

c := osvdev.DefaultClient()
c.BaseHostURL = server.URL

got, err := c.ExperimentalQueryUbuntuPackageMapping(context.Background(), &api.UbuntuPackageMappingParameters{
Ecosystem: "Ubuntu:24.04:LTS",
BinaryNames: []string{"libcurl4", "unknown"},
})
if err != nil {
t.Fatalf("ExperimentalQueryUbuntuPackageMapping failed: %v", err)
}

want := &api.UbuntuPackageMappingResponse{
Results: []*api.SourcePackages{
{SourceNames: []string{"src-libcurl4"}},
{},
},
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("ExperimentalQueryUbuntuPackageMapping mismatch (-want +got):\n%s", diff)
}
})

t.Run("automatic chunking over 1000 packages", func(t *testing.T) {
t.Parallel()

c := osvdev.DefaultClient()
c.BaseHostURL = server.URL

const total = 1250
binaryNames := make([]string, total)
wantResults := make([]*api.SourcePackages, total)
for i := range total {
name := fmt.Sprintf("pkg-%d", i)
binaryNames[i] = name
wantResults[i] = &api.SourcePackages{
SourceNames: []string{"src-" + name},
}
}

got, err := c.ExperimentalQueryUbuntuPackageMapping(context.Background(), &api.UbuntuPackageMappingParameters{
Ecosystem: "Ubuntu:22.04",
BinaryNames: binaryNames,
})
if err != nil {
t.Fatalf("ExperimentalQueryUbuntuPackageMapping chunked failed: %v", err)
}
if gotReqs := chunkedRequests.Load(); gotReqs != 2 {
t.Errorf("expected 2 chunked requests for %d packages, got %d", total, gotReqs)
}

want := &api.UbuntuPackageMappingResponse{
Results: wantResults,
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("ExperimentalQueryUbuntuPackageMapping chunked mismatch (-want +got):\n%s", diff)
}
})

t.Run("nil params error", func(t *testing.T) {
t.Parallel()

c := osvdev.DefaultClient()
c.BaseHostURL = server.URL

_, err := c.ExperimentalQueryUbuntuPackageMapping(context.Background(), nil)
wantErr := testhelper.ErrContainsStr{
Str: "params cannot be nil",
}
if diff := cmp.Diff(wantErr, err, cmpopts.EquateErrors()); diff != "" {
t.Errorf("Unexpected error (-want +got):\n%s", diff)
}
})

t.Run("invalid request error propagation", func(t *testing.T) {
t.Parallel()

c := osvdev.DefaultClient()
c.BaseHostURL = server.URL

_, err := c.ExperimentalQueryUbuntuPackageMapping(context.Background(), &api.UbuntuPackageMappingParameters{
BinaryNames: []string{"libcurl4"},
})
wantErr := testhelper.ErrContainsStr{
Str: `client error: status="400 Bad Request" body={"code":3,"message":"ecosystem is required"}`,
}
if diff := cmp.Diff(wantErr, err, cmpopts.EquateErrors()); diff != "" {
t.Errorf("Unexpected error (-want +got):\n%s", diff)
}
})
}
Loading