From 7d398f807570a9248425e99789dec0903640097b Mon Sep 17 00:00:00 2001 From: mrizzi Date: Fri, 3 Oct 2025 18:45:12 +0200 Subject: [PATCH] feat: license filter consistent for SBOM packages tab Signed-off-by: mrizzi Assisted-by: Claude Code --- .../src/common/license_filtering.rs | 35 +- modules/fundamental/src/lib.rs | 2 + .../fundamental/src/license/service/mod.rs | 88 ++-- .../fundamental/src/purl/endpoints/test.rs | 21 +- .../src/purl/model/details/purl.rs | 49 +-- .../fundamental/src/sbom/endpoints/test.rs | 381 +++++++++++------- modules/fundamental/src/sbom/service/sbom.rs | 40 +- modules/fundamental/src/sbom/service/test.rs | 104 +++++ openapi.yaml | 1 + 9 files changed, 462 insertions(+), 259 deletions(-) diff --git a/modules/fundamental/src/common/license_filtering.rs b/modules/fundamental/src/common/license_filtering.rs index 934b68deb..54e9af899 100644 --- a/modules/fundamental/src/common/license_filtering.rs +++ b/modules/fundamental/src/common/license_filtering.rs @@ -7,7 +7,7 @@ use sea_query::{ extension::postgres::PgExpr, }; use trustify_common::db::{ - ExpandLicenseExpression, + CaseLicenseTextSbomId, ExpandLicenseExpression, query::{Columns, Filtering, IntoColumns, Query, q}, }; use trustify_entity::{license, sbom_package, sbom_package_license, sbom_package_purl_ref}; @@ -104,6 +104,21 @@ pub fn create_sbom_license_filtering_base_query() -> Select Select { + sbom_package::Entity::find() + .filter(sbom_package::Column::SbomId.eq(sbom_id)) + .select_only() + .column(sbom_package::Column::NodeId) + .join(JoinType::Join, sbom_package::Relation::PackageLicense.def()) + .join( + JoinType::Join, + sbom_package_license::Relation::License.def(), + ) +} + /// Applies license filtering to a query using a two-phase SPDX/CycloneDX approach /// /// This function encapsulates the complete license filtering pattern used by both @@ -158,3 +173,21 @@ where Ok(main_query) } } + +/// Returns the case_license_text_sbom_id() PLSQL function that conditionally applies expand_license_expression() for SPDX LicenseRefs +/// +/// This function generates a SQL CASE expression that: +/// - Returns the expanded license expression when the license text contains 'LicenseRef-' (SPDX format) +/// - Returns the original license text for all other cases (including CycloneDX) +/// +/// This allows unified handling of both SPDX and CycloneDX licenses in a single query. +pub fn get_case_license_text_sbom_id() -> SimpleExpr { + SimpleExpr::FunctionCall( + Func::cust(CaseLicenseTextSbomId) + .arg(Expr::col((license::Entity, license::Column::Text))) + .arg(Expr::col(( + sbom_package_license::Entity, + sbom_package_license::Column::SbomId, + ))), + ) +} diff --git a/modules/fundamental/src/lib.rs b/modules/fundamental/src/lib.rs index e22c48d62..1b1a89afe 100644 --- a/modules/fundamental/src/lib.rs +++ b/modules/fundamental/src/lib.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + pub mod advisory; pub mod common; pub mod endpoints; diff --git a/modules/fundamental/src/license/service/mod.rs b/modules/fundamental/src/license/service/mod.rs index 2f7e1ac77..2e7675171 100644 --- a/modules/fundamental/src/license/service/mod.rs +++ b/modules/fundamental/src/license/service/mod.rs @@ -1,6 +1,6 @@ use crate::{ Error, - common::LicenseRefMapping, + common::{LicenseRefMapping, license_filtering, license_filtering::LICENSE}, license::model::{ SpdxLicenseDetails, SpdxLicenseSummary, sbom_license::{ @@ -10,11 +10,12 @@ use crate::{ }; use sea_orm::{ ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, - RelationTrait, Statement, + QueryTrait, RelationTrait, Statement, +}; +use sea_query::{ + Alias, ColumnType, Condition, Expr, JoinType, Order::Asc, PostgresQueryBuilder, query, }; -use sea_query::{ColumnType, Condition, Expr, Func, JoinType, SimpleExpr}; use serde::{Deserialize, Serialize}; -use trustify_common::db::CaseLicenseTextSbomId; use trustify_common::{ db::{ limiter::LimiterAsModelTrait, @@ -232,52 +233,35 @@ impl LicenseService { .one(connection) .await?; + const EXPANDED_LICENSE: &str = "expanded_license"; + const LICENSE_NAME: &str = "license_name"; match sbom { Some(sbom) => { - let result: Vec = LicenseRefMapping::find_by_statement(Statement::from_sql_and_values( - connection.get_database_backend(), - r#" - ( - -- Successfully parsed (during SBOM ingestion) license ID values can be - -- retrieved from the spdx_licenses column. The DISTINCT must be on lower values - -- because the license identifiers have to be managed in case-insensitive way - -- ref. https://spdx.github.io/spdx-spec/v3.0.1/annexes/spdx-license-expressions/#case-sensitivity - SELECT DISTINCT on (lower(l.spdx_licenses)) l.spdx_licenses as license_name, l.spdx_licenses as license_id - FROM sbom_package_license spl - -- 'spdx_licenses' must be unnested and sorted before joining in order to ensure consistent results - JOIN ( - SELECT id, unnest(spdx_licenses) as spdx_licenses - FROM license - ORDER BY id, spdx_licenses - ) AS l ON spl.license_id = l.id - WHERE spl.sbom_id = $1 - AND l.spdx_licenses IS NOT NULL - UNION - -- CycloneDX SBOMs has NO "LicenseRef" by specifications (hence - -- the above condition 'licensing_infos.license_id IS NULL') so - -- all the values in the license.text whose spdx_licenses is null - -- must be added to the result set. The need for the DISTINCT on lower is - -- clearly explained above. - SELECT DISTINCT ON (LOWER(l.text)) l.text as license_name, l.text as license_id - FROM sbom_package_license spl - JOIN license l ON spl.license_id = l.id - LEFT JOIN licensing_infos ON licensing_infos.sbom_id = spl.sbom_id - WHERE spl.sbom_id = $1 - AND l.spdx_licenses IS NULL - AND licensing_infos.license_id IS NULL - UNION - -- SPDX SBOMs has "LicenseRef" by specifications and they're stored in - -- licensing_infos and so their names have to be added as well - SELECT DISTINCT name as license_name, license_id - FROM licensing_infos - WHERE sbom_id = $1 - ORDER BY license_name + let expand_license_expression = sbom_package_license::Entity::find() + .select_only() + .distinct() + .column_as( + license_filtering::get_case_license_text_sbom_id(), + EXPANDED_LICENSE, + ) + .join( + JoinType::Join, + sbom_package_license::Relation::License.def(), ) - "#, - [sbom.sbom_id.into()], - )) - .all(connection) - .await?; + .filter(sbom_package_license::Column::SbomId.eq(sbom.sbom_id)); + let (sql, values) = query::Query::select() + // reported twice to keep compatibility with LicenseRefMapping currently + // exposed in the involved endpoint. + .expr_as(Expr::col(Alias::new(EXPANDED_LICENSE)), LICENSE_NAME) + .expr_as(Expr::col(Alias::new(EXPANDED_LICENSE)), "license_id") + .from_subquery(expand_license_expression.into_query(), "expanded_licenses") + .order_by(LICENSE_NAME, Asc) + .build(PostgresQueryBuilder); + let result: Vec = LicenseRefMapping::find_by_statement( + Statement::from_sql_and_values(connection.get_database_backend(), sql, values), + ) + .all(connection) + .await?; Ok(Some(result)) } None => Ok(None), @@ -290,15 +274,7 @@ impl LicenseService { paginated: Paginated, connection: &C, ) -> Result, Error> { - let case_license_text_sbom_id = SimpleExpr::FunctionCall( - Func::cust(CaseLicenseTextSbomId) - .arg(Expr::col(license::Column::Text)) - .arg(Expr::col(( - sbom_package_license::Entity, - sbom_package_license::Column::SbomId, - ))), - ); - const LICENSE: &str = "license"; + let case_license_text_sbom_id = license_filtering::get_case_license_text_sbom_id(); let limiter = license::Entity::find() .distinct() .select_only() diff --git a/modules/fundamental/src/purl/endpoints/test.rs b/modules/fundamental/src/purl/endpoints/test.rs index 81397d08c..0c101dded 100644 --- a/modules/fundamental/src/purl/endpoints/test.rs +++ b/modules/fundamental/src/purl/endpoints/test.rs @@ -281,7 +281,7 @@ async fn test_purl_license_details(ctx: &TrustifyContext) -> Result<(), anyhow:: "advisories": [], "licenses": [ { - "license_name": "(LicenseRef-8 OR LicenseRef-0 OR LicenseRef-MPL) AND (LicenseRef-Netscape OR LicenseRef-0 OR LicenseRef-8)", + "license_name": "(LGPLv2+ OR GPLv2+ OR MPL) AND (Netscape OR GPLv2+ OR LGPLv2+)", "license_type": "declared" }, { @@ -289,24 +289,7 @@ async fn test_purl_license_details(ctx: &TrustifyContext) -> Result<(), anyhow:: "license_type": "concluded" } ], - "licenses_ref_mapping": [ - { - "license_id": "LicenseRef-Netscape", - "license_name": "Netscape" - }, - { - "license_id": "LicenseRef-MPL", - "license_name": "MPL" - }, - { - "license_id": "LicenseRef-8", - "license_name": "LGPLv2+" - }, - { - "license_id": "LicenseRef-0", - "license_name": "GPLv2+" - } - ] + "licenses_ref_mapping": [] }); assert!(expected_result.contains_subset(response.clone())); Ok(()) diff --git a/modules/fundamental/src/purl/model/details/purl.rs b/modules/fundamental/src/purl/model/details/purl.rs index 127e2e7f1..6ac0bf8ec 100644 --- a/modules/fundamental/src/purl/model/details/purl.rs +++ b/modules/fundamental/src/purl/model/details/purl.rs @@ -1,11 +1,9 @@ -use crate::common::LicenseInfo; -use crate::sbom::service::sbom::LicenseBasicInfo; use crate::{ Error, advisory::model::AdvisoryHead, - common::{LicenseRefMapping, service::extract_license_ref_mappings}, + common::{LicenseInfo, LicenseRefMapping, license_filtering}, purl::model::{BasePurlHead, PurlHead, VersionedPurlHead}, - sbom::{model::SbomHead, service::SbomService}, + sbom::{model::SbomHead, service::sbom::LicenseBasicInfo}, vulnerability::model::VulnerabilityHead, }; use sea_orm::{ @@ -41,17 +39,18 @@ pub struct PurlDetails { pub base: BasePurlHead, pub advisories: Vec, pub licenses: Vec, + #[deprecated] pub licenses_ref_mapping: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema, FromQueryResult)] pub struct PurlLicenseResult { - pub sbom_id: Uuid, pub license_name: String, pub license_type: i32, } impl PurlDetails { + #[allow(deprecated)] pub async fn from_entity( package: Option, package_version: Option, @@ -107,11 +106,13 @@ impl PurlDetails { ) .await?; - let purl_license_results: Vec = sbom_package_purl_ref::Entity::find() + let licenses: Vec = sbom_package_purl_ref::Entity::find() .distinct() .select_only() - .select_column(sbom_package::Column::SbomId) - .select_column_as(license::Column::Text, "license_name") + .column_as( + license_filtering::get_case_license_text_sbom_id(), + "license_name", + ) .select_column(sbom_package_license::Column::LicenseType) .filter(sbom_package_purl_ref::Column::QualifiedPurlId.eq(qualified_package.id)) .join( @@ -123,33 +124,25 @@ impl PurlDetails { JoinType::Join, sbom_package_license::Relation::License.def(), ) - .into_model() + .into_model::() .all(tx) - .await?; - - let mut purl_license_info = Vec::new(); - let mut license_ref_mapping = Vec::new(); - - for plr in purl_license_results { - let licensing_infos = SbomService::get_licensing_infos(tx, plr.sbom_id).await?; - extract_license_ref_mappings( - plr.license_name.as_str(), - &licensing_infos, - &mut license_ref_mapping, - ); - purl_license_info.push(LicenseInfo::from(LicenseBasicInfo { - license_name: plr.license_name, - license_type: plr.license_type, - })); - } + .await? + .iter() + .map(|purl_license_result| { + LicenseInfo::from(LicenseBasicInfo { + license_name: purl_license_result.license_name.clone(), + license_type: purl_license_result.license_type, + }) + }) + .collect(); Ok(PurlDetails { head: PurlHead::from_entity(&package, &package_version, qualified_package), version: VersionedPurlHead::from_entity(&package, &package_version), base: BasePurlHead::from_entity(&package), advisories: PurlAdvisory::from_entities(purl_statuses, product_statuses, tx).await?, - licenses: purl_license_info, - licenses_ref_mapping: license_ref_mapping, + licenses, + licenses_ref_mapping: vec![], }) } } diff --git a/modules/fundamental/src/sbom/endpoints/test.rs b/modules/fundamental/src/sbom/endpoints/test.rs index fdca8e190..29de60e83 100644 --- a/modules/fundamental/src/sbom/endpoints/test.rs +++ b/modules/fundamental/src/sbom/endpoints/test.rs @@ -33,75 +33,155 @@ async fn fetch_unique_licenses(ctx: &TrustifyContext) -> Result<(), anyhow::Erro let expected_result = json!([ { "license_name": "(FTL or GPLv2+) and BSD and MIT and Public Domain and zlib with acknowledgement", - "license_id": "LicenseRef-13" + "license_id": "(FTL or GPLv2+) and BSD and MIT and Public Domain and zlib with acknowledgement" }, { - "license_name": "AFL", - "license_id": "LicenseRef-AFL" + "license_name": "(GPL+ OR Artistic) AND Artistic 2.0 AND UCD", + "license_id": "(GPL+ OR Artistic) AND Artistic 2.0 AND UCD" }, { - "license_name": "ASL 2.0", - "license_id": "LicenseRef-2" + "license_name": "(GPL+ OR Artistic) AND BSD", + "license_id": "(GPL+ OR Artistic) AND BSD" + }, + { + "license_name": "(GPL+ OR Artistic) AND HSRL AND MIT AND UCD", + "license_id": "(GPL+ OR Artistic) AND HSRL AND MIT AND UCD" + }, + { + "license_name": "(GPLv2+ OR AFL) AND GPLv2+", + "license_id": "(GPLv2+ OR AFL) AND GPLv2+" + }, + { + "license_name": "(LGPLv2+ OR GPLv2+ OR MPL) AND (Netscape OR GPLv2+ OR LGPLv2+)", + "license_id": "(LGPLv2+ OR GPLv2+ OR MPL) AND (Netscape OR GPLv2+ OR LGPLv2+)" + }, + { + "license_name": "(LGPLv3+ OR GPLv2+) AND GPLv3+", + "license_id": "(LGPLv3+ OR GPLv2+) AND GPLv3+" + }, + { + "license_name": "[{'license': {'id': 'Apache-2.0'}}]", + "license_id": "[{'license': {'id': 'Apache-2.0'}}]" + }, + { + "license_name": "[{'license': {'id': None}}]", + "license_id": "[{'license': {'id': None}}]" + }, + { + "license_name": "AFL AND GPLv2+", + "license_id": "AFL AND GPLv2+" }, { "license_name": "Apache-2.0", "license_id": "Apache-2.0" }, { - "license_name": "Artistic", - "license_id": "LicenseRef-Artistic" + "license_name": "Apache-2.0 AND BSD-2-Clause AND BSD-3-Clause", + "license_id": "Apache-2.0 AND BSD-2-Clause AND BSD-3-Clause" + }, + { + "license_name": "Apache-2.0 AND BSD-3-Clause", + "license_id": "Apache-2.0 AND BSD-3-Clause" + }, + { + "license_name": "Apache-2.0 AND JSON AND MIT", + "license_id": "Apache-2.0 AND JSON AND MIT" + }, + { + "license_name": "Apache-2.0 AND MIT", + "license_id": "Apache-2.0 AND MIT" }, { - "license_name": "Artistic 2.0", - "license_id": "LicenseRef-5" + "license_name": "Apache-2.0 AND Unlicense", + "license_id": "Apache-2.0 AND Unlicense" + }, + { + "license_name": "ASL 2.0", + "license_id": "ASL 2.0" + }, + { + "license_name": "ASL 2.0 AND BSD", + "license_id": "ASL 2.0 AND BSD" }, { "license_name": "BSD", - "license_id": "LicenseRef-BSD" + "license_id": "BSD" + }, + { + "license_name": "BSD AND GPLv2", + "license_id": "BSD AND GPLv2" + }, + { + "license_name": "BSD AND LGPLv2+", + "license_id": "BSD AND LGPLv2+" + }, + { + "license_name": "BSD OR GPL+", + "license_id": "BSD OR GPL+" }, { "license_name": "BSD-2-Clause", "license_id": "BSD-2-Clause" }, { - "license_name": "BSD-2-Clause-Views", - "license_id": "BSD-2-Clause-Views" + "license_name": "BSD-2-Clause AND BSD-2-Clause-Views", + "license_id": "BSD-2-Clause AND BSD-2-Clause-Views" + }, + { + "license_name": "BSD-2-Clause AND BSD-3-Clause", + "license_id": "BSD-2-Clause AND BSD-3-Clause" + }, + { + "license_name": "BSD-2-Clause AND BSD-3-Clause AND ISC", + "license_id": "BSD-2-Clause AND BSD-3-Clause AND ISC" + }, + { + "license_name": "BSD-2-Clause AND MIT", + "license_id": "BSD-2-Clause AND MIT" + }, + { + "license_name": "BSD-2-Clause-Views AND MIT", + "license_id": "BSD-2-Clause-Views AND MIT" }, { "license_name": "BSD-3-Clause", "license_id": "BSD-3-Clause" }, { - "license_name": "BSD-3-Clause-Clear", - "license_id": "BSD-3-Clause-Clear" + "license_name": "BSD-3-Clause AND BSD-3-Clause-Clear", + "license_id": "BSD-3-Clause AND BSD-3-Clause-Clear" }, { - "license_name": "Boost", - "license_id": "LicenseRef-Boost" + "license_name": "BSD-3-Clause AND MIT", + "license_id": "BSD-3-Clause AND MIT" }, { - "license_name": "CC-BY", - "license_id": "LicenseRef-CC-BY" + "license_name": "BSD-3-Clause OR BSD-3-Clause OR ISC", + "license_id": "BSD-3-Clause OR BSD-3-Clause OR ISC" }, { - "license_name": "CC-BY-SA-4.0", - "license_id": "CC-BY-SA-4.0" + "license_name": "CC-BY-SA-4.0 AND ISC", + "license_id": "CC-BY-SA-4.0 AND ISC" }, { "license_name": "CC0-1.0", "license_id": "CC0-1.0" }, { - "license_name": "CDDL-1.0", - "license_id": "CDDL-1.0" + "license_name": "CC0-1.0 AND MIT", + "license_id": "CC0-1.0 AND MIT" }, { - "license_name": "CDDL-1.1", - "license_id": "CDDL-1.1" + "license_name": "CDDL-1.0 OR GPL-2.0-with-classpath-exception", + "license_id": "CDDL-1.0 OR GPL-2.0-with-classpath-exception" }, { - "license_name": "Copyright only", - "license_id": "LicenseRef-7" + "license_name": "CDDL-1.1 OR GPL-2.0-with-classpath-exception", + "license_id": "CDDL-1.1 OR GPL-2.0-with-classpath-exception" + }, + { + "license_name": "Copyright only AND (Artistic OR GPL+)", + "license_id": "Copyright only AND (Artistic OR GPL+)" }, { "license_name": "EPL-1.0", @@ -109,147 +189,171 @@ async fn fetch_unique_licenses(ctx: &TrustifyContext) -> Result<(), anyhow::Erro }, { "license_name": "EPL-2.0 OR GNU General Public License, version 2 with the GNU Classpath Exception", - "license_id": "LicenseRef-14" + "license_id": "EPL-2.0 OR GNU General Public License, version 2 with the GNU Classpath Exception" + }, + { + "license_name": "GPL+ OR Artistic", + "license_id": "GPL+ OR Artistic" }, { - "license_name": "GFDL", - "license_id": "LicenseRef-GFDL" + "license_name": "GPL-2.0-only AND MIT", + "license_id": "GPL-2.0-only AND MIT" }, { - "license_name": "GPL+", - "license_id": "LicenseRef-4" + "license_name": "GPLv2", + "license_id": "GPLv2" }, { - "license_name": "GPL-2.0-only", - "license_id": "GPL-2.0-only" + "license_name": "GPLv2 AND GPLv2+ AND LGPLv2 AND MIT", + "license_id": "GPLv2 AND GPLv2+ AND LGPLv2 AND MIT" }, { - "license_name": "GPL-2.0-with-classpath-exception", - "license_id": "LicenseRef-GPL-2.0-with-classpath-exception" + "license_name": "GPLv2+", + "license_id": "GPLv2+" }, { - "license_name": "GPLv2", - "license_id": "LicenseRef-GPLv2" + "license_name": "GPLv2+ AND BSD", + "license_id": "GPLv2+ AND BSD" }, { - "license_name": "GPLv2+", - "license_id": "LicenseRef-0" + "license_name": "GPLv2+ AND GPL+", + "license_id": "GPLv2+ AND GPL+" + }, + { + "license_name": "GPLv2+ AND LGPLv2+", + "license_id": "GPLv2+ AND LGPLv2+" + }, + { + "license_name": "GPLv2+ OR LGPLv3+", + "license_id": "GPLv2+ OR LGPLv3+" }, { "license_name": "GPLv3", - "license_id": "LicenseRef-GPLv3" + "license_id": "GPLv3" }, { "license_name": "GPLv3+", - "license_id": "LicenseRef-6" + "license_id": "GPLv3+" + }, + { + "license_name": "GPLv3+ AND (GPLv2+ OR LGPLv3+)", + "license_id": "GPLv3+ AND (GPLv2+ OR LGPLv3+)" + }, + { + "license_name": "GPLv3+ AND GFDL AND BSD AND MIT", + "license_id": "GPLv3+ AND GFDL AND BSD AND MIT" }, { "license_name": "GPLv3+ and GPLv3+ with exceptions and GPLv2+ and GPLv2+ with exceptions and GPL+ and LGPLv2+ and LGPLv3+ and BSD and Public Domain and GFDL", - "license_id": "LicenseRef-11" + "license_id": "GPLv3+ and GPLv3+ with exceptions and GPLv2+ and GPLv2+ with exceptions and GPL+ and LGPLv2+ and LGPLv3+ and BSD and Public Domain and GFDL" }, { - "license_name": "HSRL", - "license_id": "LicenseRef-HSRL" + "license_name": "GPLv3+ OR BSD", + "license_id": "GPLv3+ OR BSD" }, { "license_name": "ISC", "license_id": "ISC" }, { - "license_name": "JSON", - "license_id": "JSON" + "license_name": "ISC AND JSON", + "license_id": "ISC AND JSON" + }, + { + "license_name": "ISC AND MIT", + "license_id": "ISC AND MIT" }, { "license_name": "JasPer", - "license_id": "LicenseRef-JasPer" + "license_id": "JasPer" }, { - "license_name": "LGPL-3.0-or-later", - "license_id": "LGPL-3.0-or-later" + "license_name": "JSON AND MIT", + "license_id": "JSON AND MIT" }, { - "license_name": "LGPLv2", - "license_id": "LicenseRef-LGPLv2" + "license_name": "LGPL-3.0-or-later OR Apache-2.0", + "license_id": "LGPL-3.0-or-later OR Apache-2.0" }, { - "license_name": "LGPLv2+", - "license_id": "LicenseRef-8" + "license_name": "LGPLv2", + "license_id": "LGPLv2" }, { - "license_name": "LGPLv3+", - "license_id": "LicenseRef-10" + "license_name": "LGPLv2 OR MPLv1.1", + "license_id": "LGPLv2 OR MPLv1.1" }, { - "license_name": "MIT", - "license_id": "MIT" + "license_name": "LGPLv2+", + "license_id": "LGPLv2+" }, { - "license_name": "MIT/X License, GPL/CDDL, ASL2", - "license_id": "LicenseRef-1" + "license_name": "LGPLv2+ AND GPLv2+ AND GPLv3+", + "license_id": "LGPLv2+ AND GPLv2+ AND GPLv3+" }, { - "license_name": "MPL", - "license_id": "LicenseRef-MPL" + "license_name": "LGPLv2+ AND GPLv3+", + "license_id": "LGPLv2+ AND GPLv3+" }, { - "license_name": "MPL-1.0", - "license_id": "MPL-1.0" + "license_name": "LGPLv2+ AND MIT AND GPLv2+", + "license_id": "LGPLv2+ AND MIT AND GPLv2+" }, { - "license_name": "MPL-2.0", - "license_id": "MPL-2.0" + "license_name": "LGPLv3+ AND GPLv3+ AND GFDL", + "license_id": "LGPLv3+ AND GPLv3+ AND GFDL" }, { - "license_name": "MPLv1.1", - "license_id": "LicenseRef-MPLv1.1" + "license_name": "MIT", + "license_id": "MIT" }, { - "license_name": "NOASSERTION", - "license_id": "NOASSERTION" + "license_name": "MIT AND ASL 2.0 AND CC-BY AND GPLv3", + "license_id": "MIT AND ASL 2.0 AND CC-BY AND GPLv3" }, { - "license_name": "Netscape", - "license_id": "LicenseRef-Netscape" + "license_name": "MIT AND MPL-1.0", + "license_id": "MIT AND MPL-1.0" }, { - "license_name": "Public Domain", - "license_id": "LicenseRef-12" + "license_name": "MIT AND WTFPL", + "license_id": "MIT AND WTFPL" }, { - "license_name": "Python-2.0", - "license_id": "Python-2.0" + "license_name": "MIT/X License, GPL/CDDL, ASL2", + "license_id": "MIT/X License, GPL/CDDL, ASL2" }, { - "license_name": "Sendmail", - "license_id": "Sendmail" + "license_name": "MPL-2.0", + "license_id": "MPL-2.0" }, { - "license_name": "UCD", - "license_id": "LicenseRef-UCD" + "license_name": "NOASSERTION", + "license_id": "NOASSERTION" }, { - "license_name": "Unlicense", - "license_id": "Unlicense" + "license_name": "Public Domain", + "license_id": "Public Domain" }, { - "license_name": "WTFPL", - "license_id": "WTFPL" + "license_name": "Python-2.0", + "license_id": "Python-2.0" }, { "license_name": "Zlib", "license_id": "Zlib" }, { - "license_name": "[{'license': {'id': 'Apache-2.0'}}]", - "license_id": "LicenseRef-9" + "license_name": "Zlib AND Boost", + "license_id": "Zlib AND Boost" }, { - "license_name": "[{'license': {'id': None}}]", - "license_id": "LicenseRef-3" + "license_name": "Zlib AND Sendmail AND LGPLv2+", + "license_id": "Zlib AND Sendmail AND LGPLv2+" } ]); + log::debug!("{:#}", json!(response)); assert!(expected_result.contains_subset(response.clone())); - log::debug!("{response:#?}"); let id = ctx .ingest_document("cyclonedx/application.cdx.json") @@ -318,8 +422,8 @@ async fn fetch_unique_licenses(ctx: &TrustifyContext) -> Result<(), anyhow::Erro "license_id": "The GNU General Public License, v2 with Universal FOSS Exception, v1.0" } ]); + log::debug!("{:#}", json!(response)); assert!(expected_result.contains_subset(response.clone())); - log::debug!("{response:#?}"); // properly formatted but not existent Id let req = TestRequest::get().uri("/api/v2/sbom/sha256:e5c850b67868563002801668950832278f8093308b3a3c57931f591442ed3160/all-license-ids").to_request(); @@ -345,6 +449,22 @@ async fn fetch_unique_licenses(ctx: &TrustifyContext) -> Result<(), anyhow::Erro let req = TestRequest::get().uri(&uri).to_request(); let response: Value = app.call_and_read_body_json(req).await; let expected_result = json!([ + { + "license_id": "(APACHE-2.0 OR EPL-2.0)", + "license_name": "(APACHE-2.0 OR EPL-2.0)" + }, + { + "license_id": "(EPL-2.0 OR APACHE-2.0)", + "license_name": "(EPL-2.0 OR APACHE-2.0)" + }, + { + "license_id": "APACHE-2.0 OR EPL-1.0", + "license_name": "APACHE-2.0 OR EPL-1.0" + }, + { + "license_id": "APACHE-2.0 OR EPL-2.0", + "license_name": "APACHE-2.0 OR EPL-2.0" + }, { "license_id": "Apache-2.0", "license_name": "Apache-2.0" @@ -369,6 +489,10 @@ async fn fetch_unique_licenses(ctx: &TrustifyContext) -> Result<(), anyhow::Erro "license_id": "CC0-1.0", "license_name": "CC0-1.0" }, + { + "license_id": "CC0-1.0 OR BSD-2-CLAUSE", + "license_name": "CC0-1.0 OR BSD-2-CLAUSE" + }, { "license_id": "EPL-1.0", "license_name": "EPL-1.0" @@ -378,12 +502,20 @@ async fn fetch_unique_licenses(ctx: &TrustifyContext) -> Result<(), anyhow::Erro "license_name": "EPL-2.0" }, { - "license_id": "GNU-LESSER-GENERAL-PUBLIC-LICENSE", - "license_name": "GNU-LESSER-GENERAL-PUBLIC-LICENSE" + "license_id": "EPL-2.0 OR BSD-3-CLAUSE", + "license_name": "EPL-2.0 OR BSD-3-CLAUSE" }, { - "license_id": "GPL-2.0-WITH-CLASSPATH-EXCEPTION", - "license_name": "GPL-2.0-WITH-CLASSPATH-EXCEPTION" + "license_id": "EPL-2.0 OR GPL-2.0-WITH-CLASSPATH-EXCEPTION", + "license_name": "EPL-2.0 OR GPL-2.0-WITH-CLASSPATH-EXCEPTION" + }, + { + "license_id": "EPL-2.0 OR GPL-2.0-WITH-CLASSPATH-EXCEPTION OR BSD-3-CLAUSE", + "license_name": "EPL-2.0 OR GPL-2.0-WITH-CLASSPATH-EXCEPTION OR BSD-3-CLAUSE" + }, + { + "license_id": "GNU-LESSER-GENERAL-PUBLIC-LICENSE OR APACHE-2.0", + "license_name": "GNU-LESSER-GENERAL-PUBLIC-LICENSE OR APACHE-2.0" }, { "license_id": "LGPL-2.1", @@ -394,24 +526,28 @@ async fn fetch_unique_licenses(ctx: &TrustifyContext) -> Result<(), anyhow::Erro "license_name": "LGPL-2.1+" }, { - "license_id": "LGPL-2.1-ONLY", - "license_name": "LGPL-2.1-ONLY" + "license_id": "LGPL-2.1-ONLY OR EPL-1.0", + "license_name": "LGPL-2.1-ONLY OR EPL-1.0" }, { "license_id": "LGPL-2.1-OR-LATER", "license_name": "LGPL-2.1-OR-LATER" }, + { + "license_id": "LGPL-2.1-only", + "license_name": "LGPL-2.1-only" + }, { "license_id": "MIT", "license_name": "MIT" }, { - "license_id": "MPL-1.1", - "license_name": "MPL-1.1" + "license_id": "MPL-1.1 OR LGPL-2.1-ONLY OR APACHE-2.0", + "license_name": "MPL-1.1 OR LGPL-2.1-ONLY OR APACHE-2.0" }, { - "license_id": "MPL-2.0", - "license_name": "MPL-2.0" + "license_id": "MPL-2.0 OR EPL-1.0", + "license_name": "MPL-2.0 OR EPL-1.0" }, { "license_id": "NOASSERTION", @@ -422,8 +558,8 @@ async fn fetch_unique_licenses(ctx: &TrustifyContext) -> Result<(), anyhow::Erro "license_name": "PUBLIC-DOMAIN" }, { - "license_id": "SIMILAR-TO-APACHE-LICENSE-BUT", - "license_name": "SIMILAR-TO-APACHE-LICENSE-BUT" + "license_id": "SIMILAR-TO-APACHE-LICENSE-BUT WITH THE-ACKNOWLEDGMENT-CLAUSE-REMOVED", + "license_name": "SIMILAR-TO-APACHE-LICENSE-BUT WITH THE-ACKNOWLEDGMENT-CLAUSE-REMOVED" }, { "license_id": "UPL-1.0", @@ -645,7 +781,7 @@ async fn get_packages_sbom_by_query(ctx: &TrustifyContext) -> Result<(), anyhow: ], "licenses": [ { - "license_name": "LicenseRef-2 AND LicenseRef-11 AND LicenseRef-BSD", + "license_name": "GPLv2+ AND GPLv3+ AND BSD", "license_type": "declared" }, { @@ -653,20 +789,7 @@ async fn get_packages_sbom_by_query(ctx: &TrustifyContext) -> Result<(), anyhow: "license_type": "concluded" } ], - "licenses_ref_mapping": [ - { - "license_id": "LicenseRef-2", - "license_name": "GPLv2+" - }, - { - "license_id": "LicenseRef-11", - "license_name": "GPLv3+" - }, - { - "license_id": "LicenseRef-BSD", - "license_name": "BSD" - } - ] + "licenses_ref_mapping": [] }, { "id": "SPDXRef-bad734a4-0235-478e-a95b-b20c48aa39a8", @@ -694,7 +817,7 @@ async fn get_packages_sbom_by_query(ctx: &TrustifyContext) -> Result<(), anyhow: "cpe": [], "licenses": [ { - "license_name": "LicenseRef-2 AND LicenseRef-11 AND LicenseRef-BSD", + "license_name": "GPLv2+ AND GPLv3+ AND BSD", "license_type": "declared" }, { @@ -702,20 +825,7 @@ async fn get_packages_sbom_by_query(ctx: &TrustifyContext) -> Result<(), anyhow: "license_type": "concluded" } ], - "licenses_ref_mapping": [ - { - "license_id": "LicenseRef-BSD", - "license_name": "BSD" - }, - { - "license_id": "LicenseRef-2", - "license_name": "GPLv2+" - }, - { - "license_id": "LicenseRef-11", - "license_name": "GPLv3+" - } - ] + "licenses_ref_mapping": [] } ], "total": 2 @@ -754,7 +864,7 @@ async fn get_packages_sbom_by_query(ctx: &TrustifyContext) -> Result<(), anyhow: ], "licenses": [ { - "license_name": "MIT AND LicenseRef-0", + "license_name": "MIT AND ASL 2.0", "license_type": "declared" }, { @@ -762,12 +872,7 @@ async fn get_packages_sbom_by_query(ctx: &TrustifyContext) -> Result<(), anyhow: "license_type": "concluded" } ], - "licenses_ref_mapping": [ - { - "license_id": "LicenseRef-0", - "license_name": "ASL 2.0" - } - ] + "licenses_ref_mapping": [] }, { "id": "SPDXRef-ddce7aa4-9b82-42a5-bbc7-355d963ca2d8", diff --git a/modules/fundamental/src/sbom/service/sbom.rs b/modules/fundamental/src/sbom/service/sbom.rs index 2986e188e..ff96755f8 100644 --- a/modules/fundamental/src/sbom/service/sbom.rs +++ b/modules/fundamental/src/sbom/service/sbom.rs @@ -5,6 +5,7 @@ use crate::{ LicenseRefMapping, license_filtering::{ LICENSE, apply_license_filtering, create_sbom_license_filtering_base_query, + create_sbom_package_license_filtering_base_query, get_case_license_text_sbom_id, }, }, sbom::model::{ @@ -210,6 +211,15 @@ impl SbomService { .join(JoinType::LeftJoin, sbom_package::Relation::Cpe.def()); query = join_licenses(query); + + // Add license filtering if license query is present + query = apply_license_filtering( + query, + &search, + || create_sbom_package_license_filtering_base_query(sbom_id), + sbom_package::Column::NodeId, + )?; + query = join_purls_and_cpes(query) .filtering_with( search, @@ -221,11 +231,12 @@ impl SbomService { .add_columns(sbom_package_license::Entity) .add_columns(license::Entity) .add_columns(sbom_package_purl_ref::Entity) - .translator(|field, operator, value| { - if field == "license" { - Some(format!("text{operator}{value}")) - } else { - None + .translator(|field, _operator, _value| { + match field { + // Add an empty condition (effectively TRUE) to the main SQL query + // since the real filtering by license happens in the license subqueries above + LICENSE => Some("".to_string()), + _ => None, } }), )? @@ -243,17 +254,12 @@ impl SbomService { ); let total = limiter.total().await?; - let packages = limiter.fetch().await?; - - // collect results - - let mut items = Vec::new(); - - let licensing_infos = Self::get_licensing_infos(connection, sbom_id).await?; - - for row in packages { - items.push(package_from_row(row, licensing_infos.clone())); - } + let items = limiter + .fetch() + .await? + .into_iter() + .map(|row| package_from_row(row, BTreeMap::new())) + .collect(); Ok(PaginatedResults { items, total }) } @@ -627,7 +633,7 @@ where Expr::cust_with_exprs( "coalesce(json_agg(distinct jsonb_build_object('license_name', $1, 'license_type', $2)) filter (where $3), '[]'::json)", [ - license::Column::Text.into_simple_expr(), + get_case_license_text_sbom_id(), sbom_package_license::Column::LicenseType.into_simple_expr(), license::Column::Text.is_not_null().into_simple_expr(), ], diff --git a/modules/fundamental/src/sbom/service/test.rs b/modules/fundamental/src/sbom/service/test.rs index 991568873..3f419356a 100644 --- a/modules/fundamental/src/sbom/service/test.rs +++ b/modules/fundamental/src/sbom/service/test.rs @@ -348,3 +348,107 @@ async fn fetch_sboms_filter_by_license(ctx: &TrustifyContext) -> Result<(), anyh Ok(()) } + +#[test_context(TrustifyContext)] +#[test(tokio::test)] +async fn fetch_sbom_packages_filter_by_license(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + let service = SbomService::new(ctx.db.clone()); + + // Ingest an SBOM with license information + let sbom_id = ctx + .ingest_document("spdx/mtv-2.6.json") + .await? + .id + .try_as_uid() + .unwrap(); + + // Test 1: No license filter - should return all packages + let all_packages = service + .fetch_sbom_packages(sbom_id, Query::default(), Paginated::default(), &ctx.db) + .await?; + + log::debug!("All packages count: {}", all_packages.total); + assert_eq!(all_packages.total, 5388, "Should have packages in the SBOM"); + + // Test 2: Filter by specific license that exists + let license_filtered = service + .fetch_sbom_packages( + sbom_id, + q("license=GPLv2 AND GPLv2+ AND CC-BY"), + Paginated::default(), + &ctx.db, + ) + .await?; + + log::debug!("License filtered packages: {license_filtered:#?}"); + // Should find packages with this specific license + // This validates that the license filtering is applied correctly + assert_eq!(license_filtered.total, 14); + + // Test 3: Filter by partial license match + let partial_license_filtered = service + .fetch_sbom_packages(sbom_id, q("license~GPL"), Paginated::default(), &ctx.db) + .await?; + + log::debug!("Partial license filtered packages: {partial_license_filtered:#?}"); + // Should find packages with licenses containing "GPL" + assert_eq!(partial_license_filtered.total, 448); + + // Test 4: Filter by non-existent license + let no_match = service + .fetch_sbom_packages( + sbom_id, + q("license=NONEXISTENT_LICENSE"), + Paginated::default(), + &ctx.db, + ) + .await?; + + log::debug!("No match packages: {no_match:#?}"); + assert_eq!( + no_match.total, 0, + "Should return no packages for non-existent license" + ); + assert!( + no_match.items.is_empty(), + "Items should be empty for non-existent license" + ); + + // Test 5: Combine license filter with other filters + let combined_filter = service + .fetch_sbom_packages( + sbom_id, + q("license~GPLv2 AND GPLv2+ AND CC-BY&name~qemu-kvm-"), + Paginated::default(), + &ctx.db, + ) + .await?; + + log::debug!("Combined filter packages: {combined_filter:#?}"); + // Should apply both license and name filters + assert_eq!(combined_filter.total, 11); + + // Test 6: Pagination with license filtering + if partial_license_filtered.total > 1 { + let paginated = service + .fetch_sbom_packages( + sbom_id, + q("license~GPL"), + Paginated { + offset: 0, + limit: 1, + }, + &ctx.db, + ) + .await?; + + log::debug!("Paginated license filtered packages: {paginated:#?}"); + assert_eq!(paginated.items.len(), 1, "Should respect pagination limit"); + assert_eq!( + paginated.total, partial_license_filtered.total, + "Total should match full query" + ); + } + + Ok(()) +} diff --git a/openapi.yaml b/openapi.yaml index 2ed24b811..32226879f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4767,6 +4767,7 @@ components: type: array items: $ref: '#/components/schemas/LicenseRefMapping' + deprecated: true version: $ref: '#/components/schemas/VersionedPurlHead' PurlHead: