diff --git a/bindings/go/osvdev/osvdev.go b/bindings/go/osvdev/osvdev.go index e67434b4076..d516e5d8df5 100644 --- a/bindings/go/osvdev/osvdev.go +++ b/bindings/go/osvdev/osvdev.go @@ -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" ) @@ -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 { diff --git a/bindings/go/osvdev/osvdev_test.go b/bindings/go/osvdev/osvdev_test.go index e74b8977dde..f7b700ddc7a 100644 --- a/bindings/go/osvdev/osvdev_test.go +++ b/bindings/go/osvdev/osvdev_test.go @@ -2,6 +2,11 @@ package osvdev_test import ( "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" "time" @@ -9,6 +14,8 @@ import ( "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" @@ -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, ¶ms); 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) + } + }) +}