From 83fea62a1aa9b12d4dd95ec2c08569646e4abc35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Go=C3=B1i?= Date: Tue, 2 Jun 2026 11:47:15 -0300 Subject: [PATCH 1/3] Use go-component-helper. Add support to retrieve related source purl results. Improved VERSION_NOT_FOUND detection --- go.mod | 6 +- go.sum | 14 +- pkg/handlers/cryptography_support.go | 3 - pkg/models/all_urls.go | 137 ----------- pkg/models/all_urls_test.go | 78 ------- pkg/models/common.go | 1 + pkg/models/doc.go | 2 - pkg/models/tests/licenses.sql | 12 + pkg/responsebuilder/test.json | 23 ++ pkg/usecase/component_resolver.go | 138 +++++++++++ pkg/usecase/component_resolver_test.go | 83 +++++++ pkg/usecase/component_validator.go | 91 -------- pkg/usecase/cryptography_major.go | 146 +++++++----- pkg/usecase/cryptography_search.go | 133 ++++++----- pkg/usecase/cryptography_versions_using.go | 141 ++++++----- pkg/usecase/docs.go | 8 +- pkg/usecase/library_detections.go | 85 ++++--- pkg/utils/doc.go | 23 -- pkg/utils/semver.go | 35 --- pkg/utils/semver_test.go | 260 --------------------- 20 files changed, 584 insertions(+), 835 deletions(-) create mode 100644 pkg/models/tests/licenses.sql create mode 100644 pkg/responsebuilder/test.json create mode 100644 pkg/usecase/component_resolver.go create mode 100644 pkg/usecase/component_resolver_test.go delete mode 100644 pkg/usecase/component_validator.go delete mode 100644 pkg/utils/semver.go delete mode 100644 pkg/utils/semver_test.go diff --git a/go.mod b/go.mod index 9380f33..511633d 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,12 @@ module scanoss.com/cryptography go 1.25.0 require ( - github.com/Masterminds/semver/v3 v3.4.0 + github.com/Masterminds/semver/v3 v3.5.0 github.com/golobby/config/v3 v3.4.2 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.12.3 + github.com/scanoss/go-component-helper v0.7.0 github.com/scanoss/go-grpc-helper v0.15.1 github.com/scanoss/go-purl-helper v0.3.0 github.com/scanoss/papi v0.41.0 @@ -15,7 +16,7 @@ require ( github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 - go.uber.org/zap v1.27.1 + go.uber.org/zap v1.28.0 google.golang.org/grpc v1.80.0 modernc.org/sqlite v1.48.2 ) @@ -55,6 +56,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/scanoss/go-models v0.10.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.50.0 // indirect diff --git a/go.sum b/go.sum index 7a93a86..65d5721 100644 --- a/go.sum +++ b/go.sum @@ -392,8 +392,8 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -626,8 +626,12 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/scanoss/go-component-helper v0.7.0 h1:NB0gJdzYQ8kg/UEifAkVnXzdrVT1IaoyipRaYopdsj0= +github.com/scanoss/go-component-helper v0.7.0/go.mod h1:rvdEl0tRWndm7LeaN1m7iD4QoIryI70fIfzdS+EHylY= github.com/scanoss/go-grpc-helper v0.15.1 h1:tiI7JXVj2LYpktkYVqCcUd6XyMi6aVcAKRecNR5QGCA= github.com/scanoss/go-grpc-helper v0.15.1/go.mod h1:UjrF9hewgZxstUC3+F0PJnuOXH0bChmsbcJp7e7OsH4= +github.com/scanoss/go-models v0.10.0 h1:x+9XirwTpbLQBj3X9J+D8Lauu0oYB9bxjLafkEMpFtI= +github.com/scanoss/go-models v0.10.0/go.mod h1:vRSlL4kxtprSwTIAARXVcVZ820tCQfkx6yn6038oY6A= github.com/scanoss/go-purl-helper v0.3.0 h1:zH5rcYbmYTvKms2oWrYV+8rWZ2ElLgDIOy2jZ9XhAg0= github.com/scanoss/go-purl-helper v0.3.0/go.mod h1:3CFUM/OuUp9Q58IF/yGkQhr+G4x6hJNmF8N1f0W82C4= github.com/scanoss/ipfilter/v2 v2.0.2 h1:GaB9i8kVJg9JQZm5XGStYkEpiaCVdsrj7ezI2wV/oh8= @@ -704,8 +708,10 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/pkg/handlers/cryptography_support.go b/pkg/handlers/cryptography_support.go index 82d6a0a..d2d8acb 100644 --- a/pkg/handlers/cryptography_support.go +++ b/pkg/handlers/cryptography_support.go @@ -104,9 +104,6 @@ func ConvertPurlRequestToComponentDTO(s *zap.SugaredLogger, request *common.Purl func buildComponentDTO(purl string, requirement string) dtos.ComponentDTO { p := purl req := requirement - if requirement != "" { - req = requirement - } purlParts := strings.Split(purl, "@") if len(purlParts) > 1 { p = purlParts[0] diff --git a/pkg/models/all_urls.go b/pkg/models/all_urls.go index 0a6da91..bbe3a4b 100644 --- a/pkg/models/all_urls.go +++ b/pkg/models/all_urls.go @@ -173,72 +173,6 @@ func (m *AllUrlsModel) GetUrlsByPurlNameTypeVersion(ctx context.Context, s *zap. return pickOneURL(s, allUrls, purlName, purlType, "") } -func (m *AllUrlsModel) GetUrlsByPurlNameTypeInRange(ctx context.Context, s *zap.SugaredLogger, purlName, purlType, purlRange string) ([]AllURL, error) { - if len(purlName) == 0 { - s.Infof("Please specify a valid Purl Name to query") - return []AllURL{}, errors.New("please specify a valid Purl Name to query") - } - if len(purlType) == 0 { - s.Infof("Please specify a valid Purl Type to query") - return []AllURL{}, errors.New("please specify a valid Purl Type to query") - } - if len(purlRange) == 0 { - s.Infof("Please specify a valid Purl Version range to query") - return []AllURL{}, errors.New("please specify a valid Purl Version to query") - } - var allUrls []AllURL - var filteredUrls []AllURL - err := m.db.SelectContext(ctx, &allUrls, - "SELECT package_hash AS url_hash, component, v.version_name AS version, v.semver AS semver, "+ - "purl_name, mine_id FROM all_urls u "+ - "LEFT JOIN mines m ON u.mine_id = m.id "+ - "LEFT JOIN versions v ON u.version_id = v.id "+ - "WHERE m.purl_type = $1 AND u.purl_name = $2 "+ - "AND package_hash!='404' "+ - "ORDER BY date DESC;", - purlType, purlName) - if err != nil { - s.Infof("Failed to query all urls table for %v - %v: %v", purlType, purlName, err) - return []AllURL{}, fmt.Errorf("failed to query the all urls table: %v", err) - } - rangeSpec, err := semver.NewConstraint(purlRange) - if err != nil { - return []AllURL{}, fmt.Errorf("failed to analyze range: %v", err) - } - // Track versions without semver for summary reporting - woSemver := []string{} - - // Iterate through all URLs to filter versions within the specified range - for _, u := range allUrls { - // Skip entries without semver and collect them for reporting - if u.SemVer == "" { - woSemver = append(woSemver, u.Version) - continue - } - - // Parse the semver version string - version, err := semver.NewVersion(u.SemVer) - if err != nil { - // Skip invalid semver versions - continue - } - - // Check if the version satisfies the range constraint - if rangeSpec.Check(version) { - filteredUrls = append(filteredUrls, u) - } - } - - // If all/most versions lack semver and no filtered results exist, - // add this purl to the summary for reporting purposes - if len(woSemver) >= len(allUrls) && len(allUrls) > 0 && len(filteredUrls) == 0 { - return []AllURL{}, errors.New("URLS has not valid semver") - } - s.Debugf("Found %d results for %v, %v.", len(filteredUrls), purlType, purlName) - // Pick one URL to return - return filteredUrls, nil -} - // pickOneURL takes the potential matching component/versions and selects the most appropriate one // obsolete in this application. func pickOneURL(s *zap.SugaredLogger, allUrls []AllURL, purlName, purlType, purlReq string) (AllURL, error) { @@ -308,74 +242,3 @@ func pickOneURL(s *zap.SugaredLogger, allUrls []AllURL, purlName, purlType, purl s.Debugf("Selected version: %#v", url) return url, nil // Return the best component match } - -// PickClosestUrls nolint: gocognit. -func PickClosestUrls(s *zap.SugaredLogger, allUrls []AllURL, purlName, purlType, purlReq string) ([]AllURL, error) { - if len(allUrls) == 0 { - s.Infof("No component match (in urls) found for %v, %v", purlName, purlType) - return []AllURL{}, nil - } - var c *semver.Constraints - var urlMap = make(map[*semver.Version][]AllURL) - - if len(purlReq) > 0 { - s.Debugf("Building version constraint for %v: %v", purlName, purlReq) - var err error - c, err = semver.NewConstraint(purlReq) - if err != nil { - s.Warnf("Encountered an issue parsing version constraint string '%v' (%v,%v): %v", purlReq, purlName, purlType, err) - } - } - s.Debugf("Checking versions...") - for _, url := range allUrls { - if len(url.SemVer) > 0 || len(url.Version) > 0 { - v, err := semver.NewVersion(url.Version) - if err != nil && len(url.SemVer) > 0 { - s.Debugf("Failed to parse SemVer: '%v'. Trying Version instead: %v (%v)", url.Version, url.SemVer, err) - v, err = semver.NewVersion(url.SemVer) // Semver failed, try the normal version - } - if err != nil { - s.Warnf("Encountered an issue parsing version string '%v' (%v) for %v: %v. Using v0.0.0", url.Version, url.SemVer, url, err) - v, err = semver.NewVersion("v0.0.0") // Semver failed, just use a standard version zero (for now) - } - if err == nil { - if c == nil || c.Check(v) { - found := false - for k := range urlMap { - if k.Equal(v) { - urlMap[k] = append(urlMap[k], url) - found = true - break - } - } - if !found { - urlMap[v] = append(urlMap[v], url) - } - } - } - } else { - s.Warnf("Skipping match as it doesn't have a version: %#v", url) - } - } - if len(urlMap) == 0 { // TODO should we return the latest version anyway? - s.Warnf("No component match found for %v, %v after filter %v", purlName, purlType, purlReq) - return []AllURL{}, nil - } - var versions = make([]*semver.Version, len(urlMap)) - var vi = 0 - for version := range urlMap { // Save the list of versions so they can be sorted - versions[vi] = version - vi++ - } - sort.Sort(semver.Collection(versions)) - version := versions[len(versions)-1] // Get the latest (acceptable) URL version - - url, ok := urlMap[version] // Retrieve the latest accepted URL version - if !ok { - s.Errorf("Problem retrieving URL data for %v (%v, %v)", version, purlName, purlType) - return []AllURL{}, fmt.Errorf("failed to retrieve specific URL version: %v", version) - } - - s.Debugf("Selected version: %#v", url) - return url, nil // Return the closest URLs -} diff --git a/pkg/models/all_urls_test.go b/pkg/models/all_urls_test.go index af5d3d5..f603f95 100644 --- a/pkg/models/all_urls_test.go +++ b/pkg/models/all_urls_test.go @@ -132,53 +132,6 @@ func TestAllUrlsSearchVersionRequirement(t *testing.T) { } } -func TestAllUrlsSearchVersionRange(t *testing.T) { - defer testutils.SetupTestRulesetsDir(t)() - - err := zlog.NewSugaredDevLogger() - if err != nil { - t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) - } - defer zlog.SyncZap() - ctx := ctxzap.ToContext(context.Background(), zlog.L) - s := ctxzap.Extract(ctx).Sugar() - db := sqliteSetup(t) // Setup SQL Lite DB - defer CloseDB(db) - - err = LoadTestSQLData(db, ctx) - if err != nil { - t.Fatalf("failed to load SQL test data: %v", err) - } - myConfig, err := myconfig.NewServerConfig(nil) - if err != nil { - t.Fatalf("failed to load Config: %v", err) - } - myConfig.Database.Trace = true - allUrlsModel := NewAllURLModel(db) - allUrls, err := allUrlsModel.GetUrlsByPurlNameTypeInRange(ctx, s, "scanoss/engine", "github", ">2.0") - if err != nil { - t.Errorf("all_urls.GetUrlsByPurlNameTypeInRange() error = %v", err) - } - if len(allUrls) == 0 { - t.Errorf("all_urls.GetUrlsByPurlString() No URLs returned from query") - } - fmt.Printf("All Urls Version: %#v\n", allUrls) - - _, err = allUrlsModel.GetUrlsByPurlNameTypeInRange(ctx, s, "scanoss/engine", "github", "") - if err == nil { - t.Errorf("expected error all_urls.GetUrlsByPurlNameTypeInRange() ") - } - - _, err = allUrlsModel.GetUrlsByPurlNameTypeInRange(ctx, s, "", "github", ">2.0") - if err == nil { - t.Errorf("Expected all_urls.GetUrlsByPurlNameTypeInRange() error = %v", err) - } - _, err = allUrlsModel.GetUrlsByPurlNameTypeInRange(ctx, s, "scanoss/engine", "", ">2.0") - if err == nil { - t.Errorf("Expected all_urls.GetUrlsByPurlNameTypeInRange() error = %v", err) - } -} - func TestAllUrlsSearchPurlList(t *testing.T) { defer testutils.SetupTestRulesetsDir(t)() @@ -211,37 +164,6 @@ func TestAllUrlsSearchPurlList(t *testing.T) { } } -func TestAllUrlsClosestVersionRequirement(t *testing.T) { - defer testutils.SetupTestRulesetsDir(t)() - - err := zlog.NewSugaredDevLogger() - if err != nil { - t.Fatalf("an error '%s' was not expected when opening a sugared logger", err) - } - defer zlog.SyncZap() - ctx := ctxzap.ToContext(context.Background(), zlog.L) - s := ctxzap.Extract(ctx).Sugar() - db := sqliteSetup(t) // Setup SQL Lite DB - defer CloseDB(db) - err = LoadTestSQLData(db, ctx) - if err != nil { - t.Fatalf("failed to load SQL test data: %v", err) - } - myConfig, err := myconfig.NewServerConfig(nil) - if err != nil { - t.Fatalf("failed to load Config: %v", err) - } - myConfig.Database.Trace = true - // allUrlsModel := NewAllURLModel(db) - allUrls := []AllURL{{URLHash: "0", Component: "engine", PurlName: "scanoss/engine", SemVer: "v1.0", PurlType: "github"}, - {URLHash: "1", Component: "engine", PurlName: "scanoss/engine", SemVer: "v1.1", PurlType: "github"}, - {URLHash: "2", Component: "engine", PurlName: "scanoss/engine", SemVer: "v1.2", PurlType: "github"}, - {URLHash: "3", Component: "engine", PurlName: "scanoss/engine", SemVer: "v1.3", PurlType: "github"}, - } - _, err = PickClosestUrls(s, allUrls, "scanoss/engine", "github", "v1.3") - fmt.Printf("%+v", allUrls) -} - func TestAllUrlsSearchNoLicense(t *testing.T) { defer testutils.SetupTestRulesetsDir(t)() diff --git a/pkg/models/common.go b/pkg/models/common.go index 118c9d2..e3cfbfe 100644 --- a/pkg/models/common.go +++ b/pkg/models/common.go @@ -47,6 +47,7 @@ func loadSQLData(db *sqlx.DB, ctx context.Context, filename string) error { // LoadTestSQLData loads all the required test SQL files. func LoadTestSQLData(db *sqlx.DB, ctx context.Context) error { files := []string{"../models/tests/mines.sql", "../models/tests/all_urls.sql", "../models/tests/versions.sql", + "../models/tests/licenses.sql", "../models/tests/component_crypto.sql", "../models/tests/component_crypto_libraries.sql", "../models/tests/crypto_libraries.sql"} return loadTestSQLDataFiles(db, ctx, files) diff --git a/pkg/models/doc.go b/pkg/models/doc.go index ff57af8..2ce2525 100644 --- a/pkg/models/doc.go +++ b/pkg/models/doc.go @@ -30,8 +30,6 @@ // - GetUrlsByPurlList: Batch retrieval of components // - GetUrlsByPurlNameType: Query by package name and type // - GetUrlsByPurlNameTypeVersion: Query by specific version -// - GetUrlsByPurlNameTypeInRange: Query versions within semantic version ranges -// - PickClosestUrls: Version resolution and constraint matching // // CryptoUsageModel (crypto_usage.go): // - Manages the 'component_crypto' table storing cryptographic algorithm usage diff --git a/pkg/models/tests/licenses.sql b/pkg/models/tests/licenses.sql new file mode 100644 index 0000000..6efb780 --- /dev/null +++ b/pkg/models/tests/licenses.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS licenses; +CREATE TABLE licenses +( + id integer primary key, + license_name text, + spdx_id text, + is_spdx integer +); + +INSERT INTO licenses (id, license_name, spdx_id, is_spdx) values (83, 'GPL-2.0-only', 'GPL-2.0-only', 1); +INSERT INTO licenses (id, license_name, spdx_id, is_spdx) values (2815, 'GPL-2.0-only', 'GPL-2.0-only', 1); +INSERT INTO licenses (id, license_name, spdx_id, is_spdx) values (10684, 'Apache-2.0', 'Apache-2.0', 1); diff --git a/pkg/responsebuilder/test.json b/pkg/responsebuilder/test.json new file mode 100644 index 0000000..8a83216 --- /dev/null +++ b/pkg/responsebuilder/test.json @@ -0,0 +1,23 @@ +{ + "purls": [ + { + "purl": "pkg:github/scanoss/ldb", + "requirement": ">v1.0.0", + "versions": [ + "v1.0.1", + "v1.0.2", + "v1.0.3" + ], + "algorithms": [ + { + "algorithm": "MD5", + "strength": "16" + } + ] + } + ], + "status": { + "status": "SUCCESS", + "message": "Algorithms in range Successfully retrieved" + } +} \ No newline at end of file diff --git a/pkg/usecase/component_resolver.go b/pkg/usecase/component_resolver.go new file mode 100644 index 0000000..33e0e1c --- /dev/null +++ b/pkg/usecase/component_resolver.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2025 SCANOSS.COM + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Package usecase centralises requirement resolution through the shared +// go-component-helper, so every endpoint resolves a `requirement` to concrete +// version(s) the same way, against the same SCANOSS knowledge base. +package usecase + +import ( + "context" + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/jmoiron/sqlx" + "github.com/scanoss/go-component-helper/componenthelper" + status "github.com/scanoss/go-grpc-helper/pkg/grpc/domain" + "go.uber.org/zap" + "scanoss.com/cryptography/pkg/dtos" + "scanoss.com/cryptography/pkg/models" +) + +// componentResolverMaxWorkers caps the concurrency the component helper uses when +// resolving version requirements for a batch of components. +const componentResolverMaxWorkers = 5 + +// sourceFallbackStatus flags a result that was obtained by re-querying the component's +// source-code purl after the original purl returned no cryptographic information. It is not +// a domain error code: it surfaces as a per-component info_code so the caller knows the data +// comes from the upstream source. +const sourceFallbackStatus status.StatusCode = "WARNING" + +// sourceFallbackMessage builds the info_message for a result recovered from the source-code +// purl, including the full source purl in parentheses for traceability. +func sourceFallbackMessage(sourcePurl string) string { + return fmt.Sprintf("Showing results from the source code purl (%s)", sourcePurl) +} + +// sourcePurlForFallback returns the source-code purl name/type to retry a crypto lookup against, +// when the component helper linked a usable source purl (populated only on a successful +// resolution that has a source-mine entry in the projects table). ok is false otherwise. +func sourcePurlForFallback(c componenthelper.Component) (name, purlType string, ok bool) { + if c.SourcePurl != nil && c.SourcePurl.Status.StatusCode == status.Success && c.SourcePurl.Name != "" { + return c.SourcePurl.Name, c.SourcePurl.PurlType, true + } + return "", "", false +} + +// resolveComponentVersions resolves the version requirement of every input component +// through the shared go-component-helper, which queries the same knowledge base +// (all_urls/versions/mines) this service uses. It returns one resolved Component per +// input, in the SAME order: the helper processes components concurrently and does not +// preserve order, so results are matched back to their inputs by (purl, requirement). +func resolveComponentVersions(ctx context.Context, s *zap.SugaredLogger, db *sqlx.DB, components []dtos.ComponentDTO) []componenthelper.Component { + input := make([]componenthelper.ComponentDTO, len(components)) + keys := make([]string, len(components)) + for i, c := range components { + req := c.Requirement + // Local ("file:") dependencies have no resolvable upstream version: treat as "latest". + if strings.HasPrefix(req, "file:") { + req = "" + } + input[i] = componenthelper.ComponentDTO{Purl: c.Purl, Requirement: req} + keys[i] = componentResolverKey(c.Purl, req) + } + + resolved := componenthelper.GetComponentsVersion(componenthelper.ComponentVersionCfg{ + MaxWorkers: componentResolverMaxWorkers, + Ctx: ctx, + S: s, + DB: db, + Input: input, + }) + + byKey := make(map[string]componenthelper.Component, len(resolved)) + for _, r := range resolved { + byKey[componentResolverKey(r.OriginalPurl, r.OriginalRequirement)] = r + } + + ordered := make([]componenthelper.Component, len(components)) + for i, k := range keys { + ordered[i] = byKey[k] + } + return ordered +} + +// componentResolverKey builds the lookup key used to realign concurrent helper results +// with their original inputs. The helper echoes the purl/requirement it was given back +// as OriginalPurl/OriginalRequirement, so the same pair reproduces the key. +func componentResolverKey(purl, requirement string) string { + return purl + "\x00" + requirement +} + +// filterUrlsInRange keeps the URLs whose semantic version satisfies the requirement +// constraint (and whose purl type matches, when provided). It replaces the range filtering +// previously embedded in AllUrlsModel.GetUrlsByPurlNameTypeInRange, operating on an +// already-fetched URL set so version resolution stays in the component helper / model layer. +func filterUrlsInRange(urls []models.AllURL, purlType, requirement string) ([]models.AllURL, error) { + // Reject empty and wildcard requirements: a range query needs an explicit constraint. + // (semver accepts "*" as match-all, but the range endpoints treat it as invalid.) + if requirement == "" || requirement == "*" || strings.HasPrefix(requirement, "v*") { + return nil, fmt.Errorf("invalid range requirement '%s'", requirement) + } + constraint, err := semver.NewConstraint(requirement) + if err != nil { + return nil, fmt.Errorf("failed to analyze range '%s': %w", requirement, err) + } + var filtered []models.AllURL + for _, u := range urls { + if purlType != "" && u.PurlType != "" && u.PurlType != purlType { + continue + } + if u.SemVer == "" { + continue + } + v, err := semver.NewVersion(u.SemVer) + if err != nil { + continue + } + if constraint.Check(v) { + filtered = append(filtered, u) + } + } + return filtered, nil +} diff --git a/pkg/usecase/component_resolver_test.go b/pkg/usecase/component_resolver_test.go new file mode 100644 index 0000000..e3d57ce --- /dev/null +++ b/pkg/usecase/component_resolver_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2025 SCANOSS.COM + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package usecase + +import ( + "testing" + + "github.com/scanoss/go-component-helper/componenthelper" + status "github.com/scanoss/go-grpc-helper/pkg/grpc/domain" +) + +func TestSourcePurlForFallback(t *testing.T) { + tests := []struct { + name string + component componenthelper.Component + wantName string + wantType string + wantOK bool + }{ + { + name: "no source purl", + component: componenthelper.Component{}, + wantOK: false, + }, + { + name: "source purl resolved successfully", + component: componenthelper.Component{ + SourcePurl: &componenthelper.SourcePurl{ + PurlInfo: componenthelper.PurlInfo{Name: "jonschlinkert/word-wrap", PurlType: "github"}, + Status: status.ComponentStatus{StatusCode: status.Success}, + }, + }, + wantName: "jonschlinkert/word-wrap", + wantType: "github", + wantOK: true, + }, + { + name: "source purl not found is not usable", + component: componenthelper.Component{ + SourcePurl: &componenthelper.SourcePurl{ + Status: status.ComponentStatus{StatusCode: status.ComponentNotFound}, + }, + }, + wantOK: false, + }, + { + name: "source purl success but empty name is not usable", + component: componenthelper.Component{ + SourcePurl: &componenthelper.SourcePurl{ + PurlInfo: componenthelper.PurlInfo{PurlType: "github"}, + Status: status.ComponentStatus{StatusCode: status.Success}, + }, + }, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, purlType, ok := sourcePurlForFallback(tt.component) + if ok != tt.wantOK { + t.Fatalf("sourcePurlForFallback() ok = %v, want %v", ok, tt.wantOK) + } + if ok && (name != tt.wantName || purlType != tt.wantType) { + t.Errorf("sourcePurlForFallback() = (%q, %q), want (%q, %q)", name, purlType, tt.wantName, tt.wantType) + } + }) + } +} diff --git a/pkg/usecase/component_validator.go b/pkg/usecase/component_validator.go deleted file mode 100644 index a6a370e..0000000 --- a/pkg/usecase/component_validator.go +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -/* - * Copyright (C) 2025 SCANOSS.COM - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -// Package usecase contains shared validation logic for component processing. -// This file provides validation functions used across multiple use cases to ensure -// consistent validation of component data, particularly Package URLs (PURLs) and -// version requirements. -package usecase - -import ( - "fmt" - "strings" - - "github.com/package-url/packageurl-go" - status "github.com/scanoss/go-grpc-helper/pkg/grpc/domain" - purlhelper "github.com/scanoss/go-purl-helper/pkg" - "go.uber.org/zap" - "scanoss.com/cryptography/pkg/dtos" - "scanoss.com/cryptography/pkg/utils" -) - -// parseAndValidateComponent validates and parses a component DTO, ensuring the PURL -// and version requirement are correctly formatted and semantically valid. -// -// This function performs comprehensive validation including: -// - PURL syntax validation and parsing -// - Wildcard requirement rejection ("*" and "v*" patterns) -// - Empty requirement detection -// - Semantic version requirement validation -// - PURL name extraction and validation -// -// Validation Rules: -// - The PURL must be parseable according to the PURL specification -// - Requirements cannot be "*" or start with "v*" (wildcard patterns) -// - Requirements cannot be empty strings -// - Requirements must follow valid semantic versioning syntax -// - The PURL must contain a valid package name component -// -// On validation failure, the function returns a ComponentStatus with: -// - StatusCode indicating the type of error (InvalidPurl, InvalidSemver) -// - A descriptive error message -// - An error code for API response generation -// -// On success, returns: -// - ComponentStatus with StatusCode set to Success -// - Parsed PackageURL object -// - Extracted PURL name -// -// Parameters: -// - s: Structured logger for error reporting -// - component: Component DTO containing purl and requirement fields to validate -// -// Returns: -// - componentStatus: Validation result with status code, message, and error code -// - packageURL: Parsed PackageURL object (nil on validation failure) -// - purlName: Extracted package name from PURL (nil on validation failure) -func parseAndValidateComponent(s *zap.SugaredLogger, component dtos.ComponentDTO) (status.ComponentStatus, *packageurl.PackageURL, *string) { - purl, err := purlhelper.PurlFromString(component.Purl) - if err != nil { - s.Errorf("Failed to parse purl '%s': %s", component.Purl, err) - return status.ComponentStatus{StatusCode: status.InvalidPurl, Message: fmt.Sprintf("Failed to parse purl %s", component.Purl)}, nil, nil - } - if component.Requirement == "*" || strings.HasPrefix(component.Requirement, "v*") { - return status.ComponentStatus{StatusCode: status.InvalidSemver, Message: fmt.Sprintf("Invalid requirement: %s", purl)}, nil, nil - } - if component.Requirement == "" { - return status.ComponentStatus{StatusCode: status.InvalidSemver, Message: fmt.Sprintf("Empty requirement %s", component.Requirement)}, nil, nil - } - if !utils.IsValidRequirement(component.Requirement) { - return status.ComponentStatus{StatusCode: status.InvalidSemver, Message: fmt.Sprintf("Invalid requirement: %s", component.Requirement)}, nil, nil - } - pName, err := purlhelper.PurlNameFromString(component.Purl) // Make sure we just have the bare minimum for a Purl Name - if err != nil { - s.Errorf("Failed to parse purl '%s': %s", component.Purl, err) - return status.ComponentStatus{StatusCode: status.InvalidPurl, Message: fmt.Sprintf("Failed to parse purl %s", purl)}, nil, nil - } - return status.ComponentStatus{StatusCode: status.Success}, &purl, &pName -} diff --git a/pkg/usecase/cryptography_major.go b/pkg/usecase/cryptography_major.go index 2c04682..62ca892 100644 --- a/pkg/usecase/cryptography_major.go +++ b/pkg/usecase/cryptography_major.go @@ -30,15 +30,18 @@ import ( "scanoss.com/cryptography/pkg/domain" "scanoss.com/cryptography/pkg/dtos" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/utils" ) type CryptoMajorUseCase struct { + db *sqlx.DB allUrls *models.AllUrlsModel cryptoUsage *models.CryptoUsageModel } func NewCryptoMajor(db *sqlx.DB, config *myconfig.ServerConfig) *CryptoMajorUseCase { return &CryptoMajorUseCase{ + db: db, allUrls: models.NewAllURLModel(db), cryptoUsage: models.NewCryptoUsageModel(db), } @@ -51,69 +54,98 @@ func (d CryptoMajorUseCase) GetCryptoInRange(ctx context.Context, s *zap.Sugared return domain.CryptoInRangeOutput{}, errors.New("empty list of purls") } out := domain.CryptoInRangeOutput{} - for _, component := range components { - status, packageURL, purlName := parseAndValidateComponent(s, component) + resolved := resolveComponentVersions(ctx, s, d.db, components) + for i := range resolved { + c := &resolved[i] cryptoItem := domain.CryptoInRangeOutputItem{ - Status: status, - Purl: component.Purl, - Requirement: component.Requirement, + Status: c.Status, + Purl: components[i].Purl, + Requirement: components[i].Requirement, Versions: []string{}, Algorithms: []domain.CryptoUsageItem{}, - PackageURL: packageURL, - PurlName: purlName, } - out.Cryptography = append(out.Cryptography, cryptoItem) - } - // Prepare purls to query - for i := range out.Cryptography { - c := &out.Cryptography[i] - if c.Status.StatusCode != status.Success { - continue - } - res, err := d.allUrls.GetUrlsByPurlNameTypeInRange(ctx, s, *c.PurlName, c.PackageURL.Type, c.Requirement) - if err != nil { - s.Debugf("Failed to get cryptographic algorithms: %v", err) - c.Status = status.ComponentStatus{StatusCode: status.ComponentNotFound, Message: fmt.Sprintf("Component not found %s", c.Purl)} - continue - } - if len(res) == 0 { - c.Status = status.ComponentStatus{StatusCode: status.ComponentNotFound, Message: fmt.Sprintf("Component not found %s", c.Purl)} - continue - } - var hashes []string - nonDupVersions := make(map[string]bool) - mapVersionHash := make(map[string]string) - for _, url := range res { - hashes = append(hashes, url.URLHash) - mapVersionHash[url.URLHash] = url.SemVer - } - uses, err1 := d.cryptoUsage.GetCryptoUsageByURLHashes(ctx, s, hashes) - if err1 != nil { - s.Errorf("error getting algorithms usage for purl '%s': %s", c.Purl, err) - } - // avoid duplicate algorithms - nonDupAlgorithms := make(map[models.CryptoItem]bool) - if len(uses) == 0 { - c.Status = status.ComponentStatus{StatusCode: status.NoInfo, Message: fmt.Sprintf("Component without info %s", c.Purl)} - continue - } - for _, alg := range uses { - nonDupVersions[mapVersionHash[alg.URLHash]] = true - if _, exist := nonDupAlgorithms[models.CryptoItem{Algorithm: alg.Algorithm, Strength: alg.Strength}]; !exist { - nonDupAlgorithms[models.CryptoItem{Algorithm: alg.Algorithm, Strength: alg.Strength}] = true - c.Algorithms = append(c.Algorithms, domain.CryptoUsageItem{Algorithm: alg.Algorithm, Strength: alg.Strength}) + // Resolve algorithms for components whose purl is valid and that exist in the KB. + // VERSION_NOT_FOUND is acceptable here: the helper resolves a single version, but a + // range query still inspects every known version against the requirement constraint. + if c.Status.StatusCode != status.InvalidPurl && c.Status.StatusCode != status.ComponentNotFound { + d.fillCryptoInRange(ctx, s, &cryptoItem, c.Name, c.PurlType) + // Fallback: the package purl has no crypto info, retry against the source-code purl. + if cryptoItem.Status.StatusCode == status.NoInfo { + if srcName, srcType, ok := sourcePurlForFallback(*c); ok { + srcItem := domain.CryptoInRangeOutputItem{ + Purl: components[i].Purl, Requirement: components[i].Requirement, + Versions: []string{}, Algorithms: []domain.CryptoUsageItem{}, + } + d.fillCryptoInRange(ctx, s, &srcItem, srcName, srcType) + if srcItem.Status.StatusCode == status.Success { + srcItem.Status = status.ComponentStatus{StatusCode: sourceFallbackStatus, Message: sourceFallbackMessage(c.SourcePurl.Purl)} + cryptoItem = srcItem + } + } } } - for k := range nonDupVersions { - c.Versions = append(c.Versions, k) - } - - sort.Slice(c.Versions, func(j, k int) bool { - versionA, _ := semver.NewVersion(c.Versions[j]) - versionB, _ := semver.NewVersion(c.Versions[k]) - - return versionA.LessThan(versionB) - }) + out.Cryptography = append(out.Cryptography, cryptoItem) } return out, nil } + +// fillCryptoInRange resolves the versions in range and their cryptographic algorithms for a single +// component, mutating the output item's Status, Versions and Algorithms accordingly. Version +// enumeration is delegated to the component helper / model layer (GetUrlsByPurlList + +// filterUrlsInRange) instead of a bespoke range query. +func (d CryptoMajorUseCase) fillCryptoInRange(ctx context.Context, s *zap.SugaredLogger, c *domain.CryptoInRangeOutputItem, purlName, purlType string) { + urls, err := d.allUrls.GetUrlsByPurlList(ctx, s, []utils.PurlReq{{Purl: purlName}}) + if err != nil { + s.Debugf("Failed to get urls for purl '%s': %v", c.Purl, err) + c.Status = status.ComponentStatus{StatusCode: status.ComponentNotFound, Message: fmt.Sprintf("Component not found %s", c.Purl)} + return + } + // No URLs at all: the component is not in the knowledge base. + if len(urls) == 0 { + c.Status = status.ComponentStatus{StatusCode: status.ComponentNotFound, Message: fmt.Sprintf("Component not found %s", c.Purl)} + return + } + res, err := filterUrlsInRange(urls, purlType, c.Requirement) + if err != nil { + c.Status = status.ComponentStatus{StatusCode: status.InvalidSemver, Message: fmt.Sprintf("Invalid requirement '%s' for %s", c.Requirement, c.Purl)} + return + } + // Component exists but no known version satisfies the requirement. + if len(res) == 0 { + c.Status = status.ComponentStatus{StatusCode: status.VersionNotFound, Message: fmt.Sprintf("No version of %s satisfies '%s'", c.Purl, c.Requirement)} + return + } + var hashes []string + nonDupVersions := make(map[string]bool) + mapVersionHash := make(map[string]string) + for _, url := range res { + hashes = append(hashes, url.URLHash) + mapVersionHash[url.URLHash] = url.SemVer + } + uses, err := d.cryptoUsage.GetCryptoUsageByURLHashes(ctx, s, hashes) + if err != nil { + s.Errorf("error getting algorithms usage for purl '%s': %s", c.Purl, err) + } + if len(uses) == 0 { + c.Status = status.ComponentStatus{StatusCode: status.NoInfo, Message: fmt.Sprintf("Component without info %s", c.Purl)} + return + } + // avoid duplicate algorithms + nonDupAlgorithms := make(map[models.CryptoItem]bool) + for _, alg := range uses { + nonDupVersions[mapVersionHash[alg.URLHash]] = true + if _, exist := nonDupAlgorithms[models.CryptoItem{Algorithm: alg.Algorithm, Strength: alg.Strength}]; !exist { + nonDupAlgorithms[models.CryptoItem{Algorithm: alg.Algorithm, Strength: alg.Strength}] = true + c.Algorithms = append(c.Algorithms, domain.CryptoUsageItem{Algorithm: alg.Algorithm, Strength: alg.Strength}) + } + } + for k := range nonDupVersions { + c.Versions = append(c.Versions, k) + } + sort.Slice(c.Versions, func(j, k int) bool { + versionA, _ := semver.NewVersion(c.Versions[j]) + versionB, _ := semver.NewVersion(c.Versions[k]) + return versionA.LessThan(versionB) + }) + c.Status = status.ComponentStatus{StatusCode: status.Success} +} diff --git a/pkg/usecase/cryptography_search.go b/pkg/usecase/cryptography_search.go index 4b47763..11e9100 100644 --- a/pkg/usecase/cryptography_search.go +++ b/pkg/usecase/cryptography_search.go @@ -23,9 +23,7 @@ import ( "strings" "github.com/jmoiron/sqlx" - "github.com/package-url/packageurl-go" status "github.com/scanoss/go-grpc-helper/pkg/grpc/domain" - purlhelper "github.com/scanoss/go-purl-helper/pkg" "go.uber.org/zap" myconfig "scanoss.com/cryptography/pkg/config" "scanoss.com/cryptography/pkg/domain" @@ -35,6 +33,7 @@ import ( ) type CryptoUseCase struct { + db *sqlx.DB allUrls *models.AllUrlsModel cryptoUsage *models.CryptoUsageModel } @@ -50,10 +49,14 @@ type ComponentCryptoMetadata struct { Version string Status status.ComponentStatus SelectedURLS []models.AllURL + // SourcePurl is the source-code purl linked by the component helper, used as a + // fallback target when the original purl has no cryptographic information. + SourcePurl string } func NewCrypto(db *sqlx.DB, config *myconfig.ServerConfig) *CryptoUseCase { return &CryptoUseCase{ + db: db, allUrls: models.NewAllURLModel(db), cryptoUsage: models.NewCryptoUsageModel(db), } @@ -61,11 +64,17 @@ func NewCrypto(db *sqlx.DB, config *myconfig.ServerConfig) *CryptoUseCase { // GetComponentsAlgorithms takes a list of ComponentDTO objects, searches for cryptographic usages and returns a CryptoOutput struct. func (d CryptoUseCase) GetComponentsAlgorithms(ctx context.Context, s *zap.SugaredLogger, components []dtos.ComponentDTO) (domain.CryptoOutput, error) { + return d.getComponentsAlgorithms(ctx, s, components, true) +} + +// getComponentsAlgorithms resolves cryptographic usages for the given components. When allowFallback +// is true, components that resolve with no crypto info are retried against their source-code purl. +func (d CryptoUseCase) getComponentsAlgorithms(ctx context.Context, s *zap.SugaredLogger, components []dtos.ComponentDTO, allowFallback bool) (domain.CryptoOutput, error) { if len(components) == 0 { s.Info("Empty List of Purls supplied") return domain.CryptoOutput{}, errors.New("empty list of purls") } - componentCryptoMetadata, mapPurls := d.processInputPurls(s, components) + componentCryptoMetadata, mapPurls := d.buildMetadata(ctx, s, components) s.Debugf("Component Cryptography Metadata: %v", componentCryptoMetadata) // Only query with SUCCESS status components var successPurlsToQuery []utils.PurlReq @@ -103,9 +112,47 @@ func (d CryptoUseCase) GetComponentsAlgorithms(ctx context.Context, s *zap.Sugar mapCrypto := d.buildCryptoMap(usage) output := d.processCryptoOutput(componentCryptoMetadata, mapCrypto, mapPurls) + if allowFallback { + d.applySourceFallback(ctx, s, &output, componentCryptoMetadata, components) + } return output, nil } +// applySourceFallback retries components that resolved with no crypto info against their linked +// source-code purl. When the source purl yields crypto, the result replaces the original item +// (keeping the original purl/requirement) and is flagged with a warning status so callers know +// the data comes from the upstream source. The retry runs with fallback disabled to bound depth. +func (d CryptoUseCase) applySourceFallback(ctx context.Context, s *zap.SugaredLogger, output *domain.CryptoOutput, metadata []ComponentCryptoMetadata, components []dtos.ComponentDTO) { + var srcDTOs []dtos.ComponentDTO + var outIdx []int + for i := range output.Cryptography { + code := output.Cryptography[i].Status.StatusCode + if (code == status.NoInfo || code == status.ComponentWithoutInfo) && metadata[i].SourcePurl != "" { + srcDTOs = append(srcDTOs, dtos.ComponentDTO{Purl: metadata[i].SourcePurl, Requirement: components[i].Requirement}) + outIdx = append(outIdx, i) + } + } + if len(srcDTOs) == 0 { + return + } + srcOut, err := d.getComponentsAlgorithms(ctx, s, srcDTOs, false) + if err != nil { + s.Warnf("Source-purl crypto fallback failed: %v", err) + return + } + for j := range srcOut.Cryptography { + src := srcOut.Cryptography[j] + if src.Status.StatusCode != status.Success { + continue + } + i := outIdx[j] + src.Purl = output.Cryptography[i].Purl + src.Requirement = output.Cryptography[i].Requirement + src.Status = status.ComponentStatus{StatusCode: sourceFallbackStatus, Message: sourceFallbackMessage(metadata[i].SourcePurl)} + output.Cryptography[i] = src + } +} + func (d CryptoUseCase) processUrls(urls []models.AllURL, componentCryptoMetadata []ComponentCryptoMetadata) { // Build a map from PurlName to list of URLs for easy lookup urlsByPurl := make(map[string][]models.AllURL) @@ -127,60 +174,32 @@ func (d CryptoUseCase) processUrls(urls []models.AllURL, componentCryptoMetadata } } -func (d CryptoUseCase) processPurlVersion(s *zap.SugaredLogger, purl packageurl.PackageURL, requirement string) string { - if len(requirement) > 0 && strings.HasPrefix(requirement, "file:") { - s.Debugf("Removing 'local' requirement for purl: %v (req: %v)", purl, requirement) - return "" - } - - if len(purl.Version) == 0 && len(requirement) > 0 { - ver := purlhelper.GetVersionFromReq(requirement) - if len(ver) > 0 { - return ver - } - } - return purl.Version -} - -func (d CryptoUseCase) processInputPurls(s *zap.SugaredLogger, components []dtos.ComponentDTO) ([]ComponentCryptoMetadata, map[string]bool) { - var componentCryptoMetadata []ComponentCryptoMetadata +// buildMetadata resolves the version requirement of each input component through the shared +// component helper and turns the result into the internal ComponentCryptoMetadata. The helper +// extracts any version embedded in the purl, picks the concrete version matching the requirement +// and reports the component status, so no local purl parsing or version picking is needed here. +func (d CryptoUseCase) buildMetadata(ctx context.Context, s *zap.SugaredLogger, components []dtos.ComponentDTO) ([]ComponentCryptoMetadata, map[string]bool) { + resolved := resolveComponentVersions(ctx, s, d.db, components) + componentCryptoMetadata := make([]ComponentCryptoMetadata, 0, len(resolved)) mapPurls := make(map[string]bool) - for i := range components { - c := &components[i] - purl, err := purlhelper.PurlFromString(c.Purl) - if err != nil { - componentCryptoMetadata = append(componentCryptoMetadata, - ComponentCryptoMetadata{ - Purl: c.Purl, - Status: status.ComponentStatus{StatusCode: status.InvalidPurl, Message: fmt.Sprintf("Error while parsing %s", c.Purl)}, - ComponentName: "", - Requirement: c.Requirement, - Version: c.Version, - }) - continue + for i := range resolved { + c := resolved[i] + s.Debugf("Resolved purl: %v, Name: %s, Version: %s, Status: %s", c.OriginalPurl, c.Name, c.Version, c.Status.StatusCode) + if c.Status.StatusCode == status.Success { + mapPurls[c.Name] = false } - purlName, err := purlhelper.PurlNameFromString(c.Purl) - if err != nil { - componentCryptoMetadata = append(componentCryptoMetadata, - ComponentCryptoMetadata{ - Purl: c.Purl, - Status: status.ComponentStatus{StatusCode: status.InvalidPurl, Message: fmt.Sprintf("Error while parsing %s", c.Purl)}, - ComponentName: "", - Requirement: c.Requirement, - Version: c.Version, - }) - continue + var srcPurl string + if _, _, ok := sourcePurlForFallback(c); ok { + srcPurl = c.SourcePurl.Purl } - version := d.processPurlVersion(s, purl, c.Requirement) - s.Debugf("Purl to query: %v, Name: %s, Version: %s", purl, purlName, version) - mapPurls[purlName] = false componentCryptoMetadata = append(componentCryptoMetadata, ComponentCryptoMetadata{ - Purl: c.Purl, - Version: version, - Status: status.ComponentStatus{StatusCode: status.Success, Message: ""}, - Requirement: c.Requirement, - ComponentName: purlName, + Purl: components[i].Purl, + Version: c.Version, + Status: c.Status, + Requirement: components[i].Requirement, + ComponentName: c.Name, + SourcePurl: srcPurl, }) } return componentCryptoMetadata, mapPurls @@ -202,10 +221,12 @@ func (d CryptoUseCase) collectURLHashes(s *zap.SugaredLogger, componentCryptoMet continue } - urls := componentCryptoMetadata[i].SelectedURLS - selectedURLs, err := models.PickClosestUrls(s, urls, componentCryptoMetadata[i].ComponentName, "", componentCryptoMetadata[i].Requirement) - if err != nil { - return nil, err + // Keep only the URLs for the version the component helper resolved for this requirement. + var selectedURLs []models.AllURL + for _, url := range componentCryptoMetadata[i].SelectedURLS { + if url.Version == componentCryptoMetadata[i].Version { + selectedURLs = append(selectedURLs, url) + } } componentCryptoMetadata[i].SelectedURLS = selectedURLs if len(selectedURLs) > 0 { diff --git a/pkg/usecase/cryptography_versions_using.go b/pkg/usecase/cryptography_versions_using.go index 19df672..c60c238 100644 --- a/pkg/usecase/cryptography_versions_using.go +++ b/pkg/usecase/cryptography_versions_using.go @@ -29,16 +29,18 @@ import ( "scanoss.com/cryptography/pkg/domain" "scanoss.com/cryptography/pkg/dtos" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/utils" ) type VersionsUsingCrypto struct { - s *zap.SugaredLogger + db *sqlx.DB allUrls *models.AllUrlsModel cryptoUsage *models.CryptoUsageModel } func NewVersionsUsingCrypto(db *sqlx.DB, config *myconfig.ServerConfig) *VersionsUsingCrypto { return &VersionsUsingCrypto{ + db: db, allUrls: models.NewAllURLModel(db), cryptoUsage: models.NewCryptoUsageModel(db), } @@ -47,67 +49,98 @@ func NewVersionsUsingCrypto(db *sqlx.DB, config *myconfig.ServerConfig) *Version // GetVersionsInRangeUsingCrypto takes the Crypto Input request, searches for Cryptographic and return versions that use and does not use crypto. func (d VersionsUsingCrypto) GetVersionsInRangeUsingCrypto(ctx context.Context, s *zap.SugaredLogger, components []dtos.ComponentDTO) (domain.VersionsInRangeOutput, error) { if len(components) == 0 { - d.s.Info("Empty List of Purls supplied") + s.Info("Empty List of Purls supplied") return domain.VersionsInRangeOutput{}, errors.New("empty list of purls") } out := domain.VersionsInRangeOutput{} - for _, component := range components { - status, packageURL, purlName := parseAndValidateComponent(s, component) - versionInRangeOutput := domain.VersionsInRangeUsingCryptoItem{ - Purl: component.Purl, - Status: status, - Requirement: component.Requirement, + resolved := resolveComponentVersions(ctx, s, d.db, components) + for i := range resolved { + c := &resolved[i] + item := domain.VersionsInRangeUsingCryptoItem{ + Purl: components[i].Purl, + Status: c.Status, + Requirement: components[i].Requirement, VersionsWith: []string{}, VersionsWithout: []string{}, - PackageURL: packageURL, - PurlName: purlName, } - out.Versions = append(out.Versions, versionInRangeOutput) - } - fmt.Printf("OUT %v", out) - - // Prepare purls to query - for i := range out.Versions { - component := &out.Versions[i] - if component.Status.StatusCode != status.Success { - continue - } - res, errQ := d.allUrls.GetUrlsByPurlNameTypeInRange(ctx, s, *component.PurlName, component.PackageURL.Type, component.Requirement) - if len(res) == 0 { - component.Status = status.ComponentStatus{StatusCode: status.ComponentNotFound, Message: fmt.Sprintf("Component not found %s", component.Purl)} - continue - } - _ = errQ - var hashes []string - nonDupVersions := make(map[string]bool) - mapVersionHash := make(map[string]string) - for _, url := range res { - hashes = append(hashes, url.URLHash) - mapVersionHash[url.URLHash] = url.SemVer - nonDupVersions[url.SemVer] = false - } - uses, err1 := d.cryptoUsage.GetCryptoUsageByURLHashes(ctx, s, hashes) - if err1 != nil { - d.s.Infof("error getting algorithms usage for purl '%s': %s", component.Purl, err1) - component.Status = status.ComponentStatus{StatusCode: status.ComponentWithoutInfo, Message: fmt.Sprintf("Component without info %s", component.Purl)} - continue - } - if len(uses) == 0 { - component.Status = status.ComponentStatus{StatusCode: status.NoInfo, Message: fmt.Sprintf("Component without info %s", component.Purl)} - continue - } - for _, alg := range uses { - nonDupVersions[mapVersionHash[alg.URLHash]] = true - } - for k, v := range nonDupVersions { - if v { - component.VersionsWith = append(component.VersionsWith, k) - } else { - component.VersionsWithout = append(component.VersionsWithout, k) + // VERSION_NOT_FOUND is acceptable: the helper resolves one version, but a range query + // inspects every known version against the requirement constraint. + if c.Status.StatusCode != status.InvalidPurl && c.Status.StatusCode != status.ComponentNotFound { + d.fillVersionsInRange(ctx, s, &item, c.Name, c.PurlType) + // Fallback: the package purl has no crypto info, retry against the source-code purl. + if item.Status.StatusCode == status.NoInfo { + if srcName, srcType, ok := sourcePurlForFallback(*c); ok { + srcItem := domain.VersionsInRangeUsingCryptoItem{ + Purl: components[i].Purl, Requirement: components[i].Requirement, + VersionsWith: []string{}, VersionsWithout: []string{}, + } + d.fillVersionsInRange(ctx, s, &srcItem, srcName, srcType) + if srcItem.Status.StatusCode == status.Success { + srcItem.Status = status.ComponentStatus{StatusCode: sourceFallbackStatus, Message: sourceFallbackMessage(c.SourcePurl.Purl)} + item = srcItem + } + } } } - sort.Strings(component.VersionsWith) - sort.Strings(component.VersionsWithout) + out.Versions = append(out.Versions, item) } return out, nil } + +// fillVersionsInRange splits the versions in range into those that use cryptography and those +// that do not, mutating the output item. Version enumeration is delegated to the model layer +// (GetUrlsByPurlList + filterUrlsInRange) rather than a bespoke range query. +func (d VersionsUsingCrypto) fillVersionsInRange(ctx context.Context, s *zap.SugaredLogger, item *domain.VersionsInRangeUsingCryptoItem, purlName, purlType string) { + urls, err := d.allUrls.GetUrlsByPurlList(ctx, s, []utils.PurlReq{{Purl: purlName}}) + if err != nil { + s.Debugf("Failed to get urls for purl '%s': %v", item.Purl, err) + item.Status = status.ComponentStatus{StatusCode: status.ComponentNotFound, Message: fmt.Sprintf("Component not found %s", item.Purl)} + return + } + // No URLs at all: the component is not in the knowledge base. + if len(urls) == 0 { + item.Status = status.ComponentStatus{StatusCode: status.ComponentNotFound, Message: fmt.Sprintf("Component not found %s", item.Purl)} + return + } + res, err := filterUrlsInRange(urls, purlType, item.Requirement) + if err != nil { + item.Status = status.ComponentStatus{StatusCode: status.InvalidSemver, Message: fmt.Sprintf("Invalid requirement '%s' for %s", item.Requirement, item.Purl)} + return + } + // Component exists but no known version satisfies the requirement. + if len(res) == 0 { + item.Status = status.ComponentStatus{StatusCode: status.VersionNotFound, Message: fmt.Sprintf("No version of %s satisfies '%s'", item.Purl, item.Requirement)} + return + } + var hashes []string + nonDupVersions := make(map[string]bool) + mapVersionHash := make(map[string]string) + for _, url := range res { + hashes = append(hashes, url.URLHash) + mapVersionHash[url.URLHash] = url.SemVer + nonDupVersions[url.SemVer] = false + } + uses, err := d.cryptoUsage.GetCryptoUsageByURLHashes(ctx, s, hashes) + if err != nil { + s.Infof("error getting algorithms usage for purl '%s': %s", item.Purl, err) + item.Status = status.ComponentStatus{StatusCode: status.ComponentWithoutInfo, Message: fmt.Sprintf("Component without info %s", item.Purl)} + return + } + if len(uses) == 0 { + item.Status = status.ComponentStatus{StatusCode: status.NoInfo, Message: fmt.Sprintf("Component without info %s", item.Purl)} + return + } + for _, alg := range uses { + nonDupVersions[mapVersionHash[alg.URLHash]] = true + } + for k, v := range nonDupVersions { + if v { + item.VersionsWith = append(item.VersionsWith, k) + } else { + item.VersionsWithout = append(item.VersionsWithout, k) + } + } + sort.Strings(item.VersionsWith) + sort.Strings(item.VersionsWithout) + item.Status = status.ComponentStatus{StatusCode: status.Success} +} diff --git a/pkg/usecase/docs.go b/pkg/usecase/docs.go index 8387b9a..c819900 100644 --- a/pkg/usecase/docs.go +++ b/pkg/usecase/docs.go @@ -20,9 +20,11 @@ // // The package contains the following use cases: // -// Component Validation: -// - parseAndValidateComponent: Validates and parses component DTOs, ensuring PURLs and version -// requirements are correctly formatted and semantically valid according to Package URL specifications. +// Component Resolution: +// - resolveComponentVersions (component_resolver.go): resolves each component's version +// requirement to concrete version(s) via the shared go-component-helper, which queries the +// same knowledge base (all_urls/versions/mines). It is the single source of truth for purl +// validation and version resolution across every use case. // // Cryptographic Analysis: // diff --git a/pkg/usecase/library_detections.go b/pkg/usecase/library_detections.go index e755e68..76e17c8 100644 --- a/pkg/usecase/library_detections.go +++ b/pkg/usecase/library_detections.go @@ -30,15 +30,18 @@ import ( "scanoss.com/cryptography/pkg/domain" "scanoss.com/cryptography/pkg/dtos" "scanoss.com/cryptography/pkg/models" + "scanoss.com/cryptography/pkg/utils" ) type ECDetectionUseCase struct { + db *sqlx.DB allUrls *models.AllUrlsModel usageModel *models.ECUsageModel } func NewECDetection(db *sqlx.DB, config *myconfig.ServerConfig) *ECDetectionUseCase { return &ECDetectionUseCase{ + db: db, allUrls: models.NewAllURLModel(db), usageModel: models.NewECUsageModel(db), } @@ -47,25 +50,35 @@ func NewECDetection(db *sqlx.DB, config *myconfig.ServerConfig) *ECDetectionUseC // GetDetectionsInRange takes the Crypto Input request, searches for Cryptographic usages and returns a CryptoOutput struct. func (d ECDetectionUseCase) GetDetectionsInRange(ctx context.Context, s *zap.SugaredLogger, components []dtos.ComponentDTO) (domain.ECOutput, error) { out := domain.ECOutput{} - for _, component := range components { - status, packageURL, purlName := parseAndValidateComponent(s, component) - hintOutputItem := domain.ECOutputItem{ - Purl: component.Purl, - Status: status, + resolved := resolveComponentVersions(ctx, s, d.db, components) + for i := range resolved { + c := &resolved[i] + item := domain.ECOutputItem{ + Purl: components[i].Purl, + Status: c.Status, Detections: []domain.ECDetectedItem{}, - Requirement: component.Requirement, - PackageURL: packageURL, - PurlName: purlName, + Requirement: components[i].Requirement, + Versions: []string{}, } - out.Hints = append(out.Hints, hintOutputItem) - } - - for i, component := range out.Hints { - if component.Status.StatusCode != status.Success { - continue + // VERSION_NOT_FOUND is acceptable: the helper resolves one version, but a range query + // inspects every known version against the requirement constraint. + if c.Status.StatusCode != status.InvalidPurl && c.Status.StatusCode != status.ComponentNotFound { + item = d.processSinglePurl(ctx, s, item, c.Name, c.PurlType) + // Fallback: the package purl has no detections, retry against the source-code purl. + if item.Status.StatusCode == status.NoInfo { + if srcName, srcType, ok := sourcePurlForFallback(*c); ok { + srcItem := d.processSinglePurl(ctx, s, domain.ECOutputItem{ + Purl: components[i].Purl, Requirement: components[i].Requirement, + Detections: []domain.ECDetectedItem{}, Versions: []string{}, + }, srcName, srcType) + if srcItem.Status.StatusCode == status.Success { + srcItem.Status = status.ComponentStatus{StatusCode: sourceFallbackStatus, Message: sourceFallbackMessage(c.SourcePurl.Purl)} + item = srcItem + } + } + } } - item := d.processSinglePurl(ctx, s, component) - out.Hints[i] = item + out.Hints = append(out.Hints, item) } return out, nil } @@ -221,30 +234,42 @@ func (d ECDetectionUseCase) getSortedVersions(versions map[string]bool) []string return result } -// processSinglePurl processes a single PURL and returns whether to continue processing. -func (d ECDetectionUseCase) processSinglePurl(ctx context.Context, s *zap.SugaredLogger, component domain.ECOutputItem) domain.ECOutputItem { - componentStatus := status.ComponentStatus{ - StatusCode: status.InvalidPurl, - Message: fmt.Sprintf("Invalid purl: '%s'", component.Purl), +// processSinglePurl resolves the versions in range for a component and their library detections. +// Version enumeration is delegated to the model layer (GetUrlsByPurlList + filterUrlsInRange) +// rather than a bespoke range query. +func (d ECDetectionUseCase) processSinglePurl(ctx context.Context, s *zap.SugaredLogger, component domain.ECOutputItem, purlName, purlType string) domain.ECOutputItem { + notFound := func(code status.StatusCode, msg string) domain.ECOutputItem { + return domain.ECOutputItem{ + Purl: component.Purl, + Requirement: component.Requirement, + Versions: []string{}, + Detections: []domain.ECDetectedItem{}, + Status: status.ComponentStatus{StatusCode: code, Message: msg}, + } } - res, err := d.allUrls.GetUrlsByPurlNameTypeInRange(ctx, s, *component.PurlName, component.PackageURL.Type, component.Requirement) - componentStatus.StatusCode = status.ComponentNotFound - componentStatus.Message = fmt.Sprintf("Component not found '%s'", component.Purl) + urls, err := d.allUrls.GetUrlsByPurlList(ctx, s, []utils.PurlReq{{Purl: purlName}}) if err != nil { s.Errorf("error getting urls for purl '%s': %s", component.Purl, err) - return domain.ECOutputItem{Purl: component.Purl, Versions: []string{}, Detections: []domain.ECDetectedItem{}, Status: componentStatus} + return notFound(status.ComponentNotFound, fmt.Sprintf("Component not found '%s'", component.Purl)) } - + // No URLs at all: the component is not in the knowledge base. + if len(urls) == 0 { + return notFound(status.ComponentNotFound, fmt.Sprintf("Component not found '%s'", component.Purl)) + } + res, err := filterUrlsInRange(urls, purlType, component.Requirement) + if err != nil { + return notFound(status.InvalidSemver, fmt.Sprintf("Invalid requirement '%s' for %s", component.Requirement, component.Purl)) + } + // Component exists but no known version satisfies the requirement. if len(res) == 0 { - return domain.ECOutputItem{Purl: component.Purl, Versions: []string{}, Detections: []domain.ECDetectedItem{}, Status: componentStatus} + return notFound(status.VersionNotFound, fmt.Sprintf("No version of %s satisfies '%s'", component.Purl, component.Requirement)) } item, hashes := d.processURLResults(ctx, s, res, component) if len(hashes) == 0 { - componentStatus.StatusCode = status.NoInfo - componentStatus.Message = fmt.Sprintf("Component without info '%s'", component.Purl) - return domain.ECOutputItem{Purl: component.Purl, Versions: []string{}, Detections: []domain.ECDetectedItem{}, Status: componentStatus} + return notFound(status.NoInfo, fmt.Sprintf("Component without info '%s'", component.Purl)) } + item.Requirement = component.Requirement item.Status = status.ComponentStatus{StatusCode: status.Success} return item } diff --git a/pkg/utils/doc.go b/pkg/utils/doc.go index 2e919fd..661c2dc 100644 --- a/pkg/utils/doc.go +++ b/pkg/utils/doc.go @@ -18,29 +18,6 @@ // across the cryptography service. It contains helper functions for validation and // data transformation that are used throughout the application. // -// Semantic Version Validation (semver.go): -// -// IsValidRequirement: -// - Validates version requirement strings against semantic versioning rules -// - Supports multiple comma-separated constraints -// - Accepts comparison operators: >, <, >=, <=, ~ (tilde), ^ (caret) -// - Each constraint must contain a valid semantic version -// - Returns true if all constraints are syntactically valid, false otherwise -// -// Examples of valid requirements: -// - ">=1.0.0" - Greater than or equal to 1.0.0 -// - "^2.3.4" - Compatible with 2.3.4 (caret range) -// - "~1.2.3" - Approximately 1.2.3 (tilde range) -// - ">=1.0.0, <2.0.0" - Multiple constraints (comma-separated) -// - ">1.2.3, <=4.5.6" - Range with multiple operators -// -// Examples of invalid requirements: -// - "" - Empty string -// - "*" - Wildcard (not supported) -// - "v*" - Version wildcard (not supported) -// - "invalid" - Non-semantic version string -// - "1.0" - Incomplete semantic version -// // Data Structures (purl_req.go): // // PurlReq: diff --git a/pkg/utils/semver.go b/pkg/utils/semver.go deleted file mode 100644 index 68665db..0000000 --- a/pkg/utils/semver.go +++ /dev/null @@ -1,35 +0,0 @@ -package utils - -import ( - "strings" - - "github.com/Masterminds/semver/v3" -) - -// IsValidRequirement validates whether a version requirement string contains valid semantic version constraints. -// It accepts comma-separated constraints with comparison operators (>, <, >=, <=, ~, ^). -// Each constraint must contain a valid semantic version after the operator. -// Returns true if all constraints are valid, false otherwise. -func IsValidRequirement(requirement string) bool { - constraints := strings.Split(requirement, ",") - - for _, constraint := range constraints { - constraint = strings.TrimSpace(constraint) - if constraint == "" { - return false - } - - // Extract the version part by removing comparison operators - version := constraint - version = strings.TrimLeft(version, "><=~^") - version = strings.TrimSpace(version) - version = strings.TrimLeft(version, "v") - - // Validate it's a proper semver - _, err := semver.StrictNewVersion(version) - if err != nil { - return false - } - } - return true -} diff --git a/pkg/utils/semver_test.go b/pkg/utils/semver_test.go deleted file mode 100644 index 219b234..0000000 --- a/pkg/utils/semver_test.go +++ /dev/null @@ -1,260 +0,0 @@ -package utils - -import ( - "testing" -) - -func TestIsValidRequirement(t *testing.T) { - tests := []struct { - name string - requirement string - expected bool - }{ - // Valid single constraints - { - name: "valid version with greater than", - requirement: ">1.0.0", - expected: true, - }, - { - name: "valid version with less than", - requirement: "<2.0.0", - expected: true, - }, - { - name: "valid version with greater than or equal", - requirement: ">=1.5.0", - expected: true, - }, - { - name: "valid version with less than or equal", - requirement: "<=3.0.0", - expected: true, - }, - { - name: "valid version with tilde", - requirement: "~1.2.3", - expected: true, - }, - { - name: "valid version with caret", - requirement: "^1.2.3", - expected: true, - }, - { - name: "valid version with v prefix", - requirement: ">v1.0.0", - expected: true, - }, - { - name: "valid version without operator", - requirement: "1.0.0", - expected: true, - }, - - // Valid multiple constraints - { - name: "valid multiple constraints", - requirement: ">=1.0.0, <2.0.0", - expected: true, - }, - { - name: "valid multiple constraints with v prefix", - requirement: ">=v1.0.0, invalid.version", - expected: false, - }, - { - name: "invalid semantic version", - requirement: ">1.0", - expected: false, - }, - { - name: "version with invalid characters", - requirement: ">1.0.0-alpha!", - expected: false, - }, - { - name: "empty constraint in multiple", - requirement: ">=1.0.0, , <2.0.0", - expected: false, - }, - { - name: "one invalid constraint in multiple", - requirement: ">=1.0.0, >invalid, <2.0.0", - expected: false, - }, - { - name: "only spaces", - requirement: " ", - expected: false, - }, - { - name: "only operators without version", - requirement: ">=", - expected: false, - }, - - // Edge cases - { - name: "version with pre-release", - requirement: ">1.0.0-alpha", - expected: true, - }, - { - name: "version with build metadata", - requirement: ">1.0.0+build.1", - expected: true, - }, - { - name: "version with both pre-release and build", - requirement: ">1.0.0-alpha+build.1", - expected: true, - }, - { - name: "multiple constraints with extra spaces", - requirement: " >= 1.0.0 , < 2.0.0 ", - expected: true, - }, - { - name: "version with multiple pre-release identifiers", - requirement: ">1.0.0-alpha.1.2", - expected: true, - }, - { - name: "version with numeric pre-release", - requirement: ">1.0.0-123", - expected: true, - }, - { - name: "version with complex build metadata", - requirement: ">1.0.0+20130313144700", - expected: true, - }, - { - name: "major version zero", - requirement: ">=0.1.0", - expected: true, - }, - { - name: "patch version zero", - requirement: ">=1.0.0", - expected: true, - }, - { - name: "all zeros version", - requirement: ">=0.0.0", - expected: true, - }, - { - name: "version with v prefix and tilde", - requirement: "~v1.2.3", - expected: true, - }, - { - name: "version with v prefix and caret", - requirement: "^v1.2.3", - expected: true, - }, - { - name: "multiple constraints with mixed operators", - requirement: ">=1.0.0, <=2.0.0, >1.5.0", - expected: true, - }, - { - name: "three part constraint", - requirement: ">=1.0.0, <2.0.0, >=1.5.0", - expected: true, - }, - { - name: "constraint with leading comma", - requirement: ",>=1.0.0", - expected: false, - }, - { - name: "constraint with trailing comma", - requirement: ">=1.0.0,", - expected: false, - }, - { - name: "version without minor and patch", - requirement: ">1", - expected: false, - }, - { - name: "version without patch", - requirement: ">1.0", - expected: false, - }, - { - name: "version with letters in numbers", - requirement: ">1.a.0", - expected: false, - }, - { - name: "version with negative numbers", - requirement: ">-1.0.0", - expected: false, - }, - { - name: "constraint with only tilde", - requirement: "~", - expected: false, - }, - { - name: "constraint with only caret", - requirement: "^", - expected: false, - }, - { - name: "large version numbers", - requirement: ">=999.999.999", - expected: true, - }, - { - name: "version with dots only", - requirement: ">...", - expected: false, - }, - { - name: "multiple operators without spaces", - requirement: ">=1.0.0,<=2.0.0,>1.5.0", - expected: true, - }, - { - name: "version with pre-release and multiple constraints", - requirement: ">=1.0.0-alpha, <2.0.0-beta", - expected: true, - }, - { - name: "single digit with v prefix", - requirement: "v1.0.0", - expected: true, - }, - { - name: "constraint with multiple v prefixes in list", - requirement: ">=v1.0.0, =v1.5.0", - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsValidRequirement(tt.requirement) - if result != tt.expected { - t.Errorf("IsValidRequirement(%q) = %v, expected %v", tt.requirement, result, tt.expected) - } - }) - } -} From 7bd8fb66720d83dbb7f66a3fc4e87a01438b749d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Go=C3=B1i?= Date: Tue, 2 Jun 2026 12:06:19 -0300 Subject: [PATCH 2/3] Updated changelog --- .golangci.yml | 5 ----- CHANGELOG.md | 18 ++++++++++++++++++ pkg/usecase/cryptography_search.go | 14 +++++++------- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 5270953..0fee997 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -94,11 +94,6 @@ linters: linters: - errcheck - gosec - # PickClosestUrls is intentionally complex — the semver selection logic doesn't split cleanly. - - path: pkg/models/all_urls\.go - linters: - - gocognit - text: PickClosestUrls # sqliteSetup is a dormant test helper kept for future SQLite-backed test paths. - path: pkg/models/common\.go linters: diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a5bdc..5c731c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +## [0.11.0] - 2026-06-02 +### Added +- Integrated `go-component-helper` (`componenthelper.GetComponentsVersion`) as the single source of truth for resolving and validating version requirements, querying the same knowledge base (`all_urls`/`versions`/`mines`) +- Added a source-code purl fallback: when a cryptography or library lookup returns no info, the query is retried against the component's linked source purl; recovered results are flagged with a `WARNING` info_code and the message `Showing results from the source code purl ()` +- Added `github.com/scanoss/go-component-helper` and `github.com/scanoss/go-models` dependencies + +### Changed +- Centralized requirement resolution in `pkg/usecase/component_resolver.go`, replacing the divergent per-endpoint validation/resolution logic +- Range endpoints now return `VERSION_NOT_FOUND` (instead of `COMPONENT_NOT_FOUND`) when the component exists but no known version satisfies the requirement + +### Removed +- Removed redundant local resolution helpers now provided by the component helper: `parseAndValidateComponent`, `utils.IsValidRequirement`, `processPurlVersion`, `models.PickClosestUrls`, `models.GetUrlsByPurlNameTypeInRange` + +### Fixed +- Fixed `golangci-lint` issues (line length, unused parameter and always-nil return) and removed a stale lint exclusion + + ## [0.10.0] - 2026-04-20 ### Changed - Replaced `error_message`/`error_code` fields with `info_message`/`info_code` across response builders and domain structs (algorithms, algorithms in range, encryption hints, hints in range, versions in range) @@ -115,6 +132,7 @@ - Remove from list those versions that do not contain detections - Detailed response status message. +[0.11.0]: https://github.com/scanoss/cryptography/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/scanoss/cryptography/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/scanoss/cryptography/compare/v0.8.1...v0.9.0 [0.8.1]: https://github.com/scanoss/cryptography/compare/v0.8.0...v0.8.1 diff --git a/pkg/usecase/cryptography_search.go b/pkg/usecase/cryptography_search.go index 11e9100..e4e979e 100644 --- a/pkg/usecase/cryptography_search.go +++ b/pkg/usecase/cryptography_search.go @@ -97,10 +97,7 @@ func (d CryptoUseCase) getComponentsAlgorithms(ctx context.Context, s *zap.Sugar } d.processUrls(urls, componentCryptoMetadata) } - urlHashes, err := d.collectURLHashes(s, componentCryptoMetadata) - if err != nil { - return domain.CryptoOutput{}, err - } + urlHashes := d.collectURLHashes(componentCryptoMetadata) var usage []models.CryptoUsage if len(urlHashes) > 0 { usage, err = d.cryptoUsage.GetCryptoUsageByURLHashes(ctx, s, urlHashes) @@ -122,7 +119,10 @@ func (d CryptoUseCase) getComponentsAlgorithms(ctx context.Context, s *zap.Sugar // source-code purl. When the source purl yields crypto, the result replaces the original item // (keeping the original purl/requirement) and is flagged with a warning status so callers know // the data comes from the upstream source. The retry runs with fallback disabled to bound depth. -func (d CryptoUseCase) applySourceFallback(ctx context.Context, s *zap.SugaredLogger, output *domain.CryptoOutput, metadata []ComponentCryptoMetadata, components []dtos.ComponentDTO) { +func (d CryptoUseCase) applySourceFallback( + ctx context.Context, s *zap.SugaredLogger, output *domain.CryptoOutput, + metadata []ComponentCryptoMetadata, components []dtos.ComponentDTO, +) { var srcDTOs []dtos.ComponentDTO var outIdx []int for i := range output.Cryptography { @@ -213,7 +213,7 @@ func (d CryptoUseCase) buildMetadata(ctx context.Context, s *zap.SugaredLogger, return purlMap }*/ -func (d CryptoUseCase) collectURLHashes(s *zap.SugaredLogger, componentCryptoMetadata []ComponentCryptoMetadata) ([]string, error) { +func (d CryptoUseCase) collectURLHashes(componentCryptoMetadata []ComponentCryptoMetadata) []string { var urlHashes []string for i := range componentCryptoMetadata { // Skip malformed components @@ -245,7 +245,7 @@ func (d CryptoUseCase) collectURLHashes(s *zap.SugaredLogger, componentCryptoMet componentCryptoMetadata[i].SelectedURLS = []models.AllURL{} } } - return urlHashes, nil + return urlHashes } func (d CryptoUseCase) buildCryptoMap(usage []models.CryptoUsage) map[string][]models.CryptoItem { From 9c5e21c2fbc6b7d687124489aa16995eccbbeece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Go=C3=B1i?= Date: Tue, 2 Jun 2026 13:34:13 -0300 Subject: [PATCH 3/3] Fixed comments --- pkg/usecase/component_resolver.go | 31 +++++++++++++++++++++----- pkg/usecase/component_resolver_test.go | 10 +++++++++ pkg/usecase/cryptography_search.go | 7 ++++-- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/pkg/usecase/component_resolver.go b/pkg/usecase/component_resolver.go index 33e0e1c..a914086 100644 --- a/pkg/usecase/component_resolver.go +++ b/pkg/usecase/component_resolver.go @@ -53,7 +53,8 @@ func sourceFallbackMessage(sourcePurl string) string { // when the component helper linked a usable source purl (populated only on a successful // resolution that has a source-mine entry in the projects table). ok is false otherwise. func sourcePurlForFallback(c componenthelper.Component) (name, purlType string, ok bool) { - if c.SourcePurl != nil && c.SourcePurl.Status.StatusCode == status.Success && c.SourcePurl.Name != "" { + if c.SourcePurl != nil && c.SourcePurl.Status.StatusCode == status.Success && + c.SourcePurl.Name != "" && c.SourcePurl.PurlType != "" { return c.SourcePurl.Name, c.SourcePurl.PurlType, true } return "", "", false @@ -92,7 +93,13 @@ func resolveComponentVersions(ctx context.Context, s *zap.SugaredLogger, db *sql ordered := make([]componenthelper.Component, len(components)) for i, k := range keys { - ordered[i] = byKey[k] + if r, ok := byKey[k]; ok { + ordered[i] = r + } else { + // Defensive: the helper returns one result per input, so this should not happen. + // Log it instead of silently emitting a zero-value component (treated as not found downstream). + s.Warnf("Component helper returned no result for purl %q (requirement %q); treating as not found", components[i].Purl, components[i].Requirement) + } } return ordered } @@ -104,14 +111,27 @@ func componentResolverKey(purl, requirement string) string { return purl + "\x00" + requirement } +// isWildcardRequirement reports whether a range requirement is a wildcard such as "*", "v*" +// or an operator-prefixed form like ">v*" / ">=v*". semver accepts all of these as match-all, +// so the range endpoints reject them: a range query needs a real version bound. +func isWildcardRequirement(requirement string) bool { + for _, part := range strings.Split(requirement, ",") { + norm := strings.TrimLeft(strings.TrimSpace(part), "<>=~^ ") + if norm == "*" || strings.HasPrefix(norm, "v*") { + return true + } + } + return false +} + // filterUrlsInRange keeps the URLs whose semantic version satisfies the requirement // constraint (and whose purl type matches, when provided). It replaces the range filtering // previously embedded in AllUrlsModel.GetUrlsByPurlNameTypeInRange, operating on an // already-fetched URL set so version resolution stays in the component helper / model layer. func filterUrlsInRange(urls []models.AllURL, purlType, requirement string) ([]models.AllURL, error) { // Reject empty and wildcard requirements: a range query needs an explicit constraint. - // (semver accepts "*" as match-all, but the range endpoints treat it as invalid.) - if requirement == "" || requirement == "*" || strings.HasPrefix(requirement, "v*") { + // (semver accepts "*", "v*" and even ">v*"/">=v*" as match-all, so they are all caught here.) + if requirement == "" || isWildcardRequirement(requirement) { return nil, fmt.Errorf("invalid range requirement '%s'", requirement) } constraint, err := semver.NewConstraint(requirement) @@ -120,7 +140,8 @@ func filterUrlsInRange(urls []models.AllURL, purlType, requirement string) ([]mo } var filtered []models.AllURL for _, u := range urls { - if purlType != "" && u.PurlType != "" && u.PurlType != purlType { + // When a type is requested, only keep URLs of that exact type (drop empty/other types). + if purlType != "" && u.PurlType != purlType { continue } if u.SemVer == "" { diff --git a/pkg/usecase/component_resolver_test.go b/pkg/usecase/component_resolver_test.go index e3d57ce..272e39d 100644 --- a/pkg/usecase/component_resolver_test.go +++ b/pkg/usecase/component_resolver_test.go @@ -67,6 +67,16 @@ func TestSourcePurlForFallback(t *testing.T) { }, wantOK: false, }, + { + name: "source purl success but empty purl type is not usable", + component: componenthelper.Component{ + SourcePurl: &componenthelper.SourcePurl{ + PurlInfo: componenthelper.PurlInfo{Name: "jonschlinkert/word-wrap"}, + Status: status.ComponentStatus{StatusCode: status.Success}, + }, + }, + wantOK: false, + }, } for _, tt := range tests { diff --git a/pkg/usecase/cryptography_search.go b/pkg/usecase/cryptography_search.go index e4e979e..30464c5 100644 --- a/pkg/usecase/cryptography_search.go +++ b/pkg/usecase/cryptography_search.go @@ -45,6 +45,7 @@ type CryptoWorkerStruct struct { type ComponentCryptoMetadata struct { Purl string ComponentName string + PurlType string Requirement string Version string Status status.ComponentStatus @@ -199,6 +200,7 @@ func (d CryptoUseCase) buildMetadata(ctx context.Context, s *zap.SugaredLogger, Status: c.Status, Requirement: components[i].Requirement, ComponentName: c.Name, + PurlType: c.PurlType, SourcePurl: srcPurl, }) } @@ -221,10 +223,11 @@ func (d CryptoUseCase) collectURLHashes(componentCryptoMetadata []ComponentCrypt continue } - // Keep only the URLs for the version the component helper resolved for this requirement. + // Keep only the URLs matching the resolved purl type and version, so same-name packages + // from different ecosystems don't share crypto hits. var selectedURLs []models.AllURL for _, url := range componentCryptoMetadata[i].SelectedURLS { - if url.Version == componentCryptoMetadata[i].Version { + if url.PurlType == componentCryptoMetadata[i].PurlType && url.Version == componentCryptoMetadata[i].Version { selectedURLs = append(selectedURLs, url) } }