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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion modules/fundamental/src/common/license_filtering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -104,6 +104,21 @@ pub fn create_sbom_license_filtering_base_query() -> Select<sbom_package_license
)
}

/// Creates a base query for SBOM package license filtering (targeting packages within a specific SBOM)
pub fn create_sbom_package_license_filtering_base_query(
sbom_id: sea_orm::prelude::Uuid,
) -> Select<sbom_package::Entity> {
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
Expand Down Expand Up @@ -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,
))),
)
}
2 changes: 2 additions & 0 deletions modules/fundamental/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![recursion_limit = "256"]

pub mod advisory;
pub mod common;
pub mod endpoints;
Expand Down
88 changes: 32 additions & 56 deletions modules/fundamental/src/license/service/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
Error,
common::LicenseRefMapping,
common::{LicenseRefMapping, license_filtering, license_filtering::LICENSE},
license::model::{
SpdxLicenseDetails, SpdxLicenseSummary,
sbom_license::{
Expand All @@ -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,
Expand Down Expand Up @@ -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> = 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> = LicenseRefMapping::find_by_statement(
Statement::from_sql_and_values(connection.get_database_backend(), sql, values),
)
.all(connection)
.await?;
Ok(Some(result))
}
None => Ok(None),
Expand All @@ -290,15 +274,7 @@ impl LicenseService {
paginated: Paginated,
connection: &C,
) -> Result<PaginatedResults<LicenseText>, 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()
Expand Down
21 changes: 2 additions & 19 deletions modules/fundamental/src/purl/endpoints/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,32 +281,15 @@ 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"
},
{
"license_name": "NOASSERTION",
"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(())
Expand Down
49 changes: 21 additions & 28 deletions modules/fundamental/src/purl/model/details/purl.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -41,17 +39,18 @@ pub struct PurlDetails {
pub base: BasePurlHead,
pub advisories: Vec<PurlAdvisory>,
pub licenses: Vec<LicenseInfo>,
#[deprecated]
pub licenses_ref_mapping: Vec<LicenseRefMapping>,
}

#[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<C: ConnectionTrait>(
package: Option<base_purl::Model>,
package_version: Option<versioned_purl::Model>,
Expand Down Expand Up @@ -107,11 +106,13 @@ impl PurlDetails {
)
.await?;

let purl_license_results: Vec<PurlLicenseResult> = sbom_package_purl_ref::Entity::find()
let licenses: Vec<LicenseInfo> = 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(
Expand All @@ -123,33 +124,25 @@ impl PurlDetails {
JoinType::Join,
sbom_package_license::Relation::License.def(),
)
.into_model()
.into_model::<PurlLicenseResult>()
.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![],
})
}
}
Expand Down
Loading