From 7756a1bfcddfbc3130b95b65a06f8b8cb0d6eb30 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Mon, 18 May 2026 17:36:08 +0200 Subject: [PATCH 1/6] refactor: replace status table with DeriveActiveEnum to eliminate join overhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static `status` database table (5 rows) with a PostgreSQL enum type backed by SeaORM's `DeriveActiveEnum`. This eliminates JOIN operations against the status table in every query that resolves purl_status or product_status rows, and removes the DbContext status cache in the ingestor. Key changes: - Transform entity/src/status.rs from DeriveEntityModel to DeriveActiveEnum - Change purl_status.status_id (Uuid FK) to purl_status.status (Status enum) - Change product_status.status_id (Uuid FK) to product_status.status (Status enum) - Remove all JOINs against the status table in SeaORM and raw SQL queries - Replace DbContext cache with direct Status::from_str() parsing - Add migration that converts existing data via slug-based join before dropping the status table and creating the enum type - Update UUID computation to use status.to_string().as_bytes() The HTTP API remains unchanged — status values in JSON responses are preserved as slug strings through strum's snake_case serialization. A full re-ingestion of advisories is recommended after migration to normalize primary keys (UUID computation changed from Uuid bytes to string bytes). Implements TC-4488 Assisted-by: Claude Code --- entity/src/product_status.rs | 15 +-- entity/src/purl_status.rs | 15 +-- entity/src/status.rs | 36 ++--- migration/src/lib.rs | 2 + .../src/m0002200_replace_status_with_enum.rs | 124 ++++++++++++++++++ .../fundamental/src/purl/endpoints/test.rs | 44 ++----- .../src/purl/model/details/purl.rs | 25 ++-- .../src/purl/model/details/versioned_purl.rs | 23 +--- modules/fundamental/src/purl/service/mod.rs | 16 +-- modules/fundamental/src/sbom/model/details.rs | 42 ++---- modules/fundamental/src/sbom/model/raw_sql.rs | 9 +- modules/fundamental/src/sbom/service/sbom.rs | 13 +- .../model/details/vulnerability_advisory.rs | 45 ++++--- .../src/vulnerability/service/mod.rs | 9 +- .../graph/advisory/advisory_vulnerability.rs | 24 +--- .../src/graph/advisory/product_status.rs | 8 +- .../src/graph/advisory/purl_status.rs | 10 +- modules/ingestor/src/graph/db_context.rs | 56 +------- modules/ingestor/src/graph/mod.rs | 11 +- .../ingestor/src/graph/purl/status_creator.rs | 50 ++----- .../src/service/advisory/csaf/creator.rs | 23 +--- 21 files changed, 256 insertions(+), 344 deletions(-) create mode 100644 migration/src/m0002200_replace_status_with_enum.rs diff --git a/entity/src/product_status.rs b/entity/src/product_status.rs index 5dc08b1fb..03c451819 100644 --- a/entity/src/product_status.rs +++ b/entity/src/product_status.rs @@ -1,3 +1,4 @@ +use super::status::Status; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] @@ -7,7 +8,7 @@ pub struct Model { pub id: Uuid, pub advisory_id: Uuid, pub vulnerability_id: String, - pub status_id: Uuid, + pub status: Status, pub package: Option, pub product_version_range_id: Uuid, pub context_cpe_id: Option, @@ -34,12 +35,6 @@ pub enum Relation { )] Advisory, - #[sea_orm(belongs_to = "super::status::Entity", - from = "Column::StatusId" - to = "super::status::Column::Id" - )] - Status, - #[sea_orm(belongs_to = "super::advisory_vulnerability::Entity", from = "(Column::AdvisoryId, Column::VulnerabilityId)" to = "(super::advisory_vulnerability::Column::AdvisoryId, super::advisory_vulnerability::Column::VulnerabilityId)" @@ -77,12 +72,6 @@ impl Related for Entity { } } -impl Related for Entity { - fn to() -> RelationDef { - Relation::Status.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::AdvisoryVulnerability.def() diff --git a/entity/src/purl_status.rs b/entity/src/purl_status.rs index 3c4509e96..e9ca20f4d 100644 --- a/entity/src/purl_status.rs +++ b/entity/src/purl_status.rs @@ -1,3 +1,4 @@ +use super::status::Status; use sea_orm::LinkDef; use sea_orm::entity::prelude::*; @@ -8,7 +9,7 @@ pub struct Model { pub id: Uuid, pub advisory_id: Uuid, pub vulnerability_id: String, - pub status_id: Uuid, + pub status: Status, pub base_purl_id: Uuid, pub version_range_id: Uuid, pub context_cpe_id: Option, @@ -43,12 +44,6 @@ pub enum Relation { )] Advisory, - #[sea_orm(belongs_to = "super::status::Entity", - from = "Column::StatusId" - to = "super::status::Column::Id" - )] - Status, - #[sea_orm(belongs_to = "super::advisory_vulnerability::Entity", from = "(Column::AdvisoryId, Column::VulnerabilityId)" to = "(super::advisory_vulnerability::Column::AdvisoryId, super::advisory_vulnerability::Column::VulnerabilityId)" @@ -106,12 +101,6 @@ impl Related for Entity { } } -impl Related for Entity { - fn to() -> RelationDef { - Relation::Status.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::AdvisoryVulnerability.def() diff --git a/entity/src/status.rs b/entity/src/status.rs index 00743299b..771e35e71 100644 --- a/entity/src/status.rs +++ b/entity/src/status.rs @@ -1,30 +1,6 @@ -use crate::purl_status; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "status")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: Uuid, - pub slug: String, - pub name: String, - pub description: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm(has_many = "super::purl_status::Entity")] - PackageStatus, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::PackageStatus.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} +use utoipa::ToSchema; #[derive( Copy, @@ -33,16 +9,26 @@ impl ActiveModelBehavior for ActiveModel {} Hash, Debug, PartialEq, + EnumIter, + DeriveActiveEnum, strum::EnumString, strum::Display, Serialize, Deserialize, + ToSchema, )] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "status")] #[strum(serialize_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum Status { + #[sea_orm(string_value = "affected")] Affected, + #[sea_orm(string_value = "fixed")] Fixed, + #[sea_orm(string_value = "not_affected")] NotAffected, + #[sea_orm(string_value = "under_investigation")] UnderInvestigation, + #[sea_orm(string_value = "recommended")] Recommended, } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index f40c951a8..aab792744 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -58,6 +58,7 @@ mod m0002160_fix_ref_fk; mod m0002170_drop_cvss_tables; mod m0002180_advisory_fk_indexes; mod m0002190_vulnerability_base_score_advisory; +mod m0002200_replace_status_with_enum; pub trait MigratorExt: Send { fn build_migrations() -> Migrations; @@ -132,6 +133,7 @@ impl MigratorExt for Migrator { .normal(m0002170_drop_cvss_tables::Migration) .normal(m0002180_advisory_fk_indexes::Migration) .normal(m0002190_vulnerability_base_score_advisory::Migration) + .normal(m0002200_replace_status_with_enum::Migration) } } diff --git a/migration/src/m0002200_replace_status_with_enum.rs b/migration/src/m0002200_replace_status_with_enum.rs new file mode 100644 index 000000000..e6b496fe6 --- /dev/null +++ b/migration/src/m0002200_replace_status_with_enum.rs @@ -0,0 +1,124 @@ +use sea_orm_migration::prelude::*; +use trustify_common::db::create_enum_if_not_exists; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(Clone, DeriveIden)] +enum StatusEnum { + #[sea_orm(iden = "status")] + Table, + Affected, + Fixed, + NotAffected, + UnderInvestigation, + Recommended, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 1. Add temporary text columns to hold migrated status slugs. + // We can't create the enum type yet because the status TABLE occupies the name. + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE purl_status + ADD COLUMN IF NOT EXISTS status_text text; + ALTER TABLE product_status + ADD COLUMN IF NOT EXISTS status_text text; + "#, + ) + .await?; + + // 2. Migrate data from the old status table via slug-based join. + // Handles duplicate "fixed" UUIDs since both map to the same slug. + manager + .get_connection() + .execute_unprepared( + r#" + UPDATE purl_status + SET status_text = s.slug + FROM status s + WHERE s.id = purl_status.status_id + AND purl_status.status_text IS NULL; + + UPDATE product_status + SET status_text = s.slug + FROM status s + WHERE s.id = product_status.status_id + AND product_status.status_text IS NULL; + "#, + ) + .await?; + + // 3. Drop the old status_id FK columns (removes FK constraints on the status table) + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE purl_status + DROP COLUMN IF EXISTS status_id; + ALTER TABLE product_status + DROP COLUMN IF EXISTS status_id; + "#, + ) + .await?; + + // 4. Drop the old status table (now safe since no FK references remain) + manager + .get_connection() + .execute_unprepared("DROP TABLE IF EXISTS status CASCADE;") + .await?; + + // 5. Create the PostgreSQL enum type 'status' (idempotent). + // Now safe since the table (and its implicit composite type) is gone. + create_enum_if_not_exists( + manager, + StatusEnum::Table, + [ + StatusEnum::Affected, + StatusEnum::Fixed, + StatusEnum::NotAffected, + StatusEnum::UnderInvestigation, + StatusEnum::Recommended, + ], + ) + .await?; + + // 6. Add the enum columns and cast the migrated text data + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE purl_status + ADD COLUMN IF NOT EXISTS status status; + ALTER TABLE product_status + ADD COLUMN IF NOT EXISTS status status; + + UPDATE purl_status SET status = status_text::status; + UPDATE product_status SET status = status_text::status; + + ALTER TABLE purl_status + ALTER COLUMN status SET NOT NULL; + ALTER TABLE product_status + ALTER COLUMN status SET NOT NULL; + + ALTER TABLE purl_status + DROP COLUMN IF EXISTS status_text; + ALTER TABLE product_status + DROP COLUMN IF EXISTS status_text; + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Err(DbErr::Migration( + "Cannot reverse status table to enum migration".to_string(), + )) + } +} diff --git a/modules/fundamental/src/purl/endpoints/test.rs b/modules/fundamental/src/purl/endpoints/test.rs index 028cb0d0d..ed2b23766 100644 --- a/modules/fundamental/src/purl/endpoints/test.rs +++ b/modules/fundamental/src/purl/endpoints/test.rs @@ -440,14 +440,14 @@ async fn get_recommendations_dedup(ctx: &TrustifyContext) -> Result<(), anyhow:: Ok(()) } -/// Verifies that a custom vulnerability status is reflected in the recommendation response. +/// Verifies that a non-default vulnerability status is reflected in the recommendation response. #[test_context(TrustifyContext)] #[test(actix_web::test)] async fn get_recommendations_other_status(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; - use trustify_entity::{purl_status, status}; + use trustify_entity::{purl_status, status::Status}; - // Given a package with a vulnerability whose status is overridden to a custom value + // Given a package with a vulnerability whose status is overridden to "recommended" ctx.graph .ingest_qualified_package( &Purl::from_str("pkg:cargo/hyper@0.14.1-redhat-00001")?, @@ -457,15 +457,6 @@ async fn get_recommendations_other_status(ctx: &TrustifyContext) -> Result<(), a ctx.ingest_documents(["osv/RUSTSEC-2021-0079.json"]).await?; - let custom_status_id = Uuid::new_v4(); - let custom_status = status::ActiveModel { - id: Set(custom_status_id), - slug: Set("custom_status".to_string()), - name: Set("Custom Status".to_string()), - description: Set(Some("A custom status for testing".to_string())), - }; - status::Entity::insert(custom_status).exec(&ctx.db).await?; - let purl_statuses = purl_status::Entity::find() .filter(purl_status::Column::VulnerabilityId.eq("CVE-2021-32714")) .all(&ctx.db) @@ -475,7 +466,7 @@ async fn get_recommendations_other_status(ctx: &TrustifyContext) -> Result<(), a for ps in purl_statuses { let mut active: purl_status::ActiveModel = ps.into(); - active.status_id = Set(custom_status_id); + active.status = Set(Status::Recommended); active.update(&ctx.db).await?; } @@ -485,7 +476,7 @@ async fn get_recommendations_other_status(ctx: &TrustifyContext) -> Result<(), a log::info!("{recommendations:#?}"); - // Then the vulnerability status reflects the custom status + // Then the vulnerability status reflects the updated status let entry = &recommendations["recommendations"].as_object().unwrap()["pkg:cargo/hyper@0.14.1"][0]; let vulns = entry["vulnerabilities"].as_array().unwrap(); @@ -494,7 +485,7 @@ async fn get_recommendations_other_status(ctx: &TrustifyContext) -> Result<(), a .find(|v| v["id"].as_str().unwrap() == "CVE-2021-32714") .unwrap(); - assert_eq!(vuln["status"], "custom_status"); + assert_eq!(vuln["status"], "Recommended"); Ok(()) } @@ -635,7 +626,7 @@ async fn get_recommendations_fallback_package_str( #[test(actix_web::test)] async fn get_recommendations_fixed_status(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; - use trustify_entity::{purl_status, status}; + use trustify_entity::{purl_status, status::Status}; // Given a package with a vulnerability whose status is set to "fixed" ctx.graph @@ -647,25 +638,6 @@ async fn get_recommendations_fixed_status(ctx: &TrustifyContext) -> Result<(), a ctx.ingest_documents(["osv/RUSTSEC-2021-0079.json"]).await?; - let fixed_status = status::Entity::find() - .filter(status::Column::Slug.eq("fixed")) - .one(&ctx.db) - .await?; - - let status_id = if let Some(s) = fixed_status { - s.id - } else { - let id = Uuid::new_v4(); - let new_status = status::ActiveModel { - id: Set(id), - slug: Set("fixed".to_string()), - name: Set("Fixed".to_string()), - description: Set(Some("Vulnerability has been fixed".to_string())), - }; - status::Entity::insert(new_status).exec(&ctx.db).await?; - id - }; - let purl_statuses = purl_status::Entity::find() .filter(purl_status::Column::VulnerabilityId.eq("CVE-2021-32714")) .all(&ctx.db) @@ -673,7 +645,7 @@ async fn get_recommendations_fixed_status(ctx: &TrustifyContext) -> Result<(), a for ps in purl_statuses { let mut active: purl_status::ActiveModel = ps.into(); - active.status_id = Set(status_id); + active.status = Set(Status::Fixed); active.update(&ctx.db).await?; } diff --git a/modules/fundamental/src/purl/model/details/purl.rs b/modules/fundamental/src/purl/model/details/purl.rs index 60e5d8fb8..7bf7fa67c 100644 --- a/modules/fundamental/src/purl/model/details/purl.rs +++ b/modules/fundamental/src/purl/model/details/purl.rs @@ -28,8 +28,8 @@ use trustify_common::{ use trustify_entity::{ advisory, advisory_vulnerability_score, base_purl, cpe, license, organization, product, product_status, product_version, product_version_range, purl_status, qualified_purl, sbom, - sbom_license_expanded, sbom_node, sbom_node_purl_ref, sbom_package_license, status, - version_range, versioned_purl, vulnerability, + sbom_license_expanded, sbom_node, sbom_node_purl_ref, sbom_package_license, version_range, + versioned_purl, vulnerability, }; use trustify_module_ingestor::common::{Deprecation, DeprecationForExt}; use utoipa::ToSchema; @@ -181,7 +181,6 @@ async fn get_product_statuses_for_purl( ) .join(JoinType::Join, cpe::Relation::Product.def()) .join(JoinType::LeftJoin, product::Relation::ProductVersion.def()) - .join(JoinType::Join, product_status::Relation::Status.def()) .join(JoinType::Join, product_status::Relation::Advisory.def()) .join( JoinType::Join, @@ -195,7 +194,7 @@ async fn get_product_statuses_for_purl( )) .distinct_on([ (product_status::Entity, product_status::Column::ContextCpeId), - (product_status::Entity, product_status::Column::StatusId), + (product_status::Entity, product_status::Column::Status), (product_status::Entity, product_status::Column::Package), ( product_status::Entity, @@ -203,7 +202,7 @@ async fn get_product_statuses_for_purl( ), ]) .order_by_asc(product_status::Column::ContextCpeId) - .order_by_asc(product_status::Column::StatusId) + .order_by_asc(product_status::Column::Status) .order_by_asc(product_status::Column::Package) .order_by_asc(product_status::Column::VulnerabilityId); @@ -280,7 +279,7 @@ impl PurlAdvisory { let purl_status = PurlStatus::new( &product_status.vulnerability, &product_status.advisory, - product_status.status.slug.clone(), + product_status.status_slug.clone(), Some(VersionRange::from_entity(product_status.version_range)?), Some(product_status.cpe.to_string()), tx, @@ -397,11 +396,7 @@ impl PurlStatus { package_status: &purl_status::Model, tx: &C, ) -> Result { - let status = status::Entity::find_by_id(package_status.status_id) - .one(tx) - .await? - .map(|e| e.slug) - .unwrap_or("unknown".into()); + let status = package_status.status.to_string(); let cpe = match package_status.context_cpe_id { Some(context_cpe) => { let cpe = cpe::Entity::find_by_id(context_cpe).one(tx).await?; @@ -485,17 +480,19 @@ pub struct ProductStatusCatcher { advisory: advisory::Model, vulnerability: vulnerability::Model, cpe: trustify_entity::cpe::Model, - status: status::Model, + status_slug: String, version_range: version_range::Model, } impl FromQueryResult for ProductStatusCatcher { fn from_query_result(res: &QueryResult, _pre: &str) -> Result { + let product_status_model: product_status::Model = + Self::from_query_result_multi_model(res, "", product_status::Entity)?; Ok(Self { advisory: Self::from_query_result_multi_model(res, "", advisory::Entity)?, vulnerability: Self::from_query_result_multi_model(res, "", vulnerability::Entity)?, cpe: Self::from_query_result_multi_model(res, "", trustify_entity::cpe::Entity)?, - status: Self::from_query_result_multi_model(res, "", status::Entity)?, + status_slug: product_status_model.status.to_string(), version_range: Self::from_query_result_multi_model(res, "", version_range::Entity)?, }) } @@ -507,7 +504,7 @@ impl FromQueryResultMultiModel for ProductStatusCatcher { .try_model_columns(advisory::Entity)? .try_model_columns(vulnerability::Entity)? .try_model_columns(trustify_entity::cpe::Entity)? - .try_model_columns(status::Entity)? + .try_model_columns(product_status::Entity)? .try_model_columns(version_range::Entity) } } diff --git a/modules/fundamental/src/purl/model/details/versioned_purl.rs b/modules/fundamental/src/purl/model/details/versioned_purl.rs index d2ab97113..4177a92c3 100644 --- a/modules/fundamental/src/purl/model/details/versioned_purl.rs +++ b/modules/fundamental/src/purl/model/details/versioned_purl.rs @@ -10,13 +10,13 @@ use sea_orm::{ ColumnTrait, ConnectionTrait, EntityTrait, LoaderTrait, ModelTrait, QueryFilter, QuerySelect, RelationTrait, prelude::Uuid, }; +use std::collections::HashMap; use sea_query::{Asterisk, Expr, Func, JoinType, SimpleExpr}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use trustify_common::{db::VersionMatches, memo::Memo}; use trustify_entity::{ advisory, base_purl, organization, purl_status, qualified_purl, remediation, - remediation_purl_status, status, version_range, versioned_purl, vulnerability, + remediation_purl_status, version_range, versioned_purl, vulnerability, }; use utoipa::ToSchema; @@ -115,16 +115,6 @@ impl VersionedPurlAdvisory { .map(|(advisory, org)| (advisory.id, org.clone())) .collect(); - // Batch load status entities to avoid more queries - let status_models = statuses.load_one(status::Entity, tx).await?; - - // Create a HashMap for fast status lookup by status ID - let status_map: HashMap> = statuses - .iter() - .zip(status_models.iter()) - .map(|(purl_status, status)| (purl_status.status_id, status.clone())) - .collect(); - let remediations = statuses .load_many_to_many(remediation::Entity, remediation_purl_status::Entity, tx) .await?; @@ -138,10 +128,10 @@ impl VersionedPurlAdvisory { .zip(remediations.iter()) { if let (Some(vulnerability), Some(advisory)) = (vuln, advisory) { - let status_model = status_map.get(&purl_status.status_id).cloned().flatten(); + let status_slug = purl_status.status.to_string(); let qualified_package_status = - VersionedPurlStatus::from_entity(vulnerability, status_model, remediations, tx) + VersionedPurlStatus::from_entity(vulnerability, status_slug, remediations, tx) .await?; if let Some(entry) = results.iter_mut().find(|e| e.head.uuid == advisory.id) { @@ -176,13 +166,10 @@ pub struct VersionedPurlStatus { impl VersionedPurlStatus { pub async fn from_entity( vuln: &vulnerability::Model, - status_model: Option, + status: String, remediations: &[remediation::Model], tx: &C, ) -> Result { - let status = status_model - .map(|e| e.slug) - .unwrap_or("unknown".to_string()); Ok(Self { vulnerability: VulnerabilityHead::from_vulnerability_entity( diff --git a/modules/fundamental/src/purl/service/mod.rs b/modules/fundamental/src/purl/service/mod.rs index 351d3fd7a..76917a82b 100644 --- a/modules/fundamental/src/purl/service/mod.rs +++ b/modules/fundamental/src/purl/service/mod.rs @@ -36,7 +36,7 @@ use trustify_entity::{ advisory, base_purl, license, purl_status, qualified_purl::{self, CanonicalPurl}, remediation, remediation_purl_status, sbom_license_expanded, sbom_node, sbom_node_purl_ref, - sbom_package_license, status, version_range, versioned_purl, vulnerability, + sbom_package_license, version_range, versioned_purl, vulnerability, }; use trustify_module_ingestor::common::Deprecation; @@ -612,15 +612,6 @@ impl PurlService { .load_one(advisory::Entity, connection) .instrument(info_span!("loading advisories")) .await?; - let status_models = all_statuses - .load_one(status::Entity, connection) - .instrument(info_span!("loading statuses")) - .await?; - let status_slug_map: HashMap<_, _> = status_models - .into_iter() - .flatten() - .map(|s| (s.id, s.slug)) - .collect(); let remediations = all_statuses .load_many_to_many( remediation::Entity, @@ -637,10 +628,7 @@ impl PurlService { .zip(remediations) { if let (Some(v), Some(advisory)) = (vuln, advisory) { - let slug = status_slug_map - .get(&ps.status_id) - .cloned() - .unwrap_or_else(|| "unknown".to_string()); + let slug = ps.status.to_string(); statuses_by_base .entry(ps.base_purl_id) .or_default() diff --git a/modules/fundamental/src/sbom/model/details.rs b/modules/fundamental/src/sbom/model/details.rs index b108f1be2..4333b9b70 100644 --- a/modules/fundamental/src/sbom/model/details.rs +++ b/modules/fundamental/src/sbom/model/details.rs @@ -15,7 +15,7 @@ use sea_orm::{ ConnectionTrait, DbBackend, DbErr, EntityTrait, FromQueryResult, JoinType, ModelTrait, QueryFilter, QueryResult, QuerySelect, RelationTrait, Statement, }; -use sea_query::{Asterisk, Expr, Func, PgFunc, SimpleExpr}; +use sea_query::{Asterisk, Expr, ExprTrait, Func, PgFunc, SimpleExpr}; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, @@ -26,7 +26,7 @@ use trustify_common::{db::VersionMatches, memo::Memo}; use trustify_entity::{ advisory, advisory_vulnerability, advisory_vulnerability_score, base_purl, cpe, organization, purl_status, qualified_purl, sbom, sbom_node, sbom_node_purl_ref, sbom_package, - source_document, status, version_range, versioned_purl, vulnerability, + source_document, version_range, versioned_purl, vulnerability, }; use utoipa::ToSchema; use uuid::Uuid; @@ -42,7 +42,7 @@ struct IdSet { advisory_vulnerability_vulnerability_id: String, vulnerability_id: String, context_cpe_id: Option, - status_id: Uuid, + status: String, organization_id: Option, } @@ -57,7 +57,7 @@ impl FromQueryResult for IdSet { advisory_vulnerability_vulnerability_id: res.try_get("", "av_vulnerability_id")?, vulnerability_id: res.try_get("", "vulnerability_id")?, context_cpe_id: res.try_get("", "cpe_id").ok(), - status_id: res.try_get("", "status_id")?, + status: res.try_get("", "status")?, organization_id: res.try_get("", "organization_id").ok(), }) } @@ -100,7 +100,7 @@ impl SbomDetails { .column_as(qualified_purl::Column::Id, "qualified_purl_id") .column_as(sbom_package::Column::SbomId, "sbom_id") .column_as(sbom_package::Column::NodeId, "node_id") - .column_as(status::Column::Id, "status_id") + .column_as(purl_status::Column::Status, "status") .column_as(cpe::Column::Id, "cpe_id") .column_as(organization::Column::Id, "organization_id") .join(JoinType::Join, sbom_package::Relation::Node.def()) @@ -111,12 +111,14 @@ impl SbomDetails { qualified_purl::Relation::VersionedPurl.def(), ) .join(JoinType::LeftJoin, versioned_purl::Relation::BasePurl.def()) - .join(JoinType::Join, base_purl::Relation::PurlStatus.def()) - .join(JoinType::Join, purl_status::Relation::Status.def()); + .join(JoinType::Join, base_purl::Relation::PurlStatus.def()); if !statuses.is_empty() { - query = query - .filter(Expr::col((status::Entity, status::Column::Slug)).is_in(statuses.clone())); + query = query.filter( + Expr::col((purl_status::Entity, purl_status::Column::Status)) + .cast_as(sea_query::Alias::new("text")) + .is_in(statuses.clone()), + ); } query = query.filter(Expr::cust_with_values( @@ -193,7 +195,6 @@ impl SbomDetails { let mut advisory_vulnerability_ids_set: BTreeSet<(Uuid, String)> = BTreeSet::new(); let mut vulnerability_ids_set: BTreeSet = BTreeSet::new(); let mut cpe_ids_set: BTreeSet = BTreeSet::new(); - let mut status_ids_set: BTreeSet = BTreeSet::new(); let mut organization_ids_set: BTreeSet = BTreeSet::new(); for id_set in &id_sets { @@ -208,7 +209,6 @@ impl SbomDetails { if let Some(cpe_id) = id_set.context_cpe_id { cpe_ids_set.insert(cpe_id); } - status_ids_set.insert(id_set.status_id); if let Some(org_id) = id_set.organization_id { organization_ids_set.insert(org_id); } @@ -221,7 +221,6 @@ impl SbomDetails { advisory_vulnerability_ids_set.into_iter().collect(); let vulnerability_ids: Vec = vulnerability_ids_set.into_iter().collect(); let cpe_ids: Vec = cpe_ids_set.into_iter().collect(); - let status_ids: Vec = status_ids_set.into_iter().collect(); let organization_ids: Vec = organization_ids_set.into_iter().collect(); // Pre-fetch all entities in bulk and build lookup maps with Arc @@ -314,16 +313,6 @@ impl SbomDetails { .collect(); log::debug!("Pre-fetched {} cpes", cpes_map.len()); - let statuses_map: BTreeMap> = status::Entity::find() - .filter(Expr::col(status::Column::Id).eq(PgFunc::any(status_ids))) - .all(tx) - .instrument(info_span!("fetch status")) - .await? - .into_iter() - .map(|s| (s.id, Arc::new(s))) - .collect(); - log::debug!("Pre-fetched {} statuses", statuses_map.len()); - let organizations_map: BTreeMap> = organization::Entity::find() .filter(Expr::col(organization::Column::Id).eq(PgFunc::any(organization_ids))) @@ -414,9 +403,6 @@ impl SbomDetails { let context_cpe = id_set .context_cpe_id .and_then(|id| cpes_map.get(&id).cloned()); - let status = statuses_map.get(&id_set.status_id).ok_or_else(|| { - Error::NotFound(format!("Status {} not found in lookup", id_set.status_id)) - })?; let organization = id_set .organization_id .and_then(|id| organizations_map.get(&id).cloned()); @@ -429,7 +415,7 @@ impl SbomDetails { advisory_vulnerability: Arc::clone(advisory_vulnerability), vulnerability: Arc::clone(vulnerability), context_cpe, - status: Arc::clone(status), + status: id_set.status.clone(), organization, }); } @@ -488,7 +474,7 @@ impl SbomAdvisory { }; let sbom_status = if let Some(status) = advisory.status.iter_mut().find(|status| { - status.status == each.status.slug + status.status == each.status && status.vulnerability.identifier == each.vulnerability.id }) { status @@ -496,7 +482,7 @@ impl SbomAdvisory { let status = SbomStatus::new( &each.advisory_vulnerability, &each.vulnerability, - each.status.slug.clone(), + each.status.clone(), status_cpe, vec![], // Look up pre-fetched scores from the map diff --git a/modules/fundamental/src/sbom/model/raw_sql.rs b/modules/fundamental/src/sbom/model/raw_sql.rs index 31005b7d5..02c53c8b2 100644 --- a/modules/fundamental/src/sbom/model/raw_sql.rs +++ b/modules/fundamental/src/sbom/model/raw_sql.rs @@ -110,7 +110,7 @@ pub fn product_advisory_info_sql() -> String { ps.id as product_status_id, ps.advisory_id, ps.vulnerability_id, - ps.status_id, + ps.status, ps.context_cpe_id, sp.qualified_purl_id, sp.sbom_id, @@ -128,7 +128,7 @@ pub fn product_advisory_info_sql() -> String { ps.id as product_status_id, ps.advisory_id, ps.vulnerability_id, - ps.status_id, + ps.status, ps.context_cpe_id, sp.qualified_purl_id, sp.sbom_id, @@ -157,20 +157,19 @@ pub fn product_advisory_info_sql() -> String { m.qualified_purl_id AS "qualified_purl_id", m.sbom_id AS "sbom_id", m.node_id AS "node_id", - "status"."id" AS "status_id", + m.status::text AS "status", "cpe"."id" AS "cpe_id", "organization"."id" AS "organization_id" FROM all_matches m JOIN sbom_package ON sbom_package.sbom_id = m.sbom_id AND sbom_package.node_id = m.node_id JOIN sbom_node ON sbom_node.sbom_id = m.sbom_id AND sbom_node.node_id = m.node_id - JOIN "status" ON m.status_id = "status"."id" JOIN "advisory" ON m.advisory_id = "advisory"."id" LEFT JOIN "organization" ON "advisory"."issuer_id" = "organization"."id" JOIN "advisory_vulnerability" ON m.advisory_id = "advisory_vulnerability"."advisory_id" AND m.vulnerability_id = "advisory_vulnerability"."vulnerability_id" JOIN "vulnerability" ON "advisory_vulnerability"."vulnerability_id" = "vulnerability"."id" LEFT JOIN "cpe" ON m.context_cpe_id = "cpe"."id" - WHERE ($2::text[] = ARRAY[]::text[] OR "status"."slug" = ANY($2::text[])) + WHERE ($2::text[] = ARRAY[]::text[] OR m.status::text = ANY($2::text[])) AND "advisory"."deprecated" = false "# .to_string() diff --git a/modules/fundamental/src/sbom/service/sbom.rs b/modules/fundamental/src/sbom/service/sbom.rs index 3d85d47e7..3fcb3fa7a 100644 --- a/modules/fundamental/src/sbom/service/sbom.rs +++ b/modules/fundamental/src/sbom/service/sbom.rs @@ -39,8 +39,8 @@ use trustify_entity::{ license, organization, package_relates_to_package, qualified_purl, relationship::Relationship, sbom, sbom_ai, sbom_group_assignment, sbom_license_expanded, sbom_node, sbom_node_cpe_ref, - sbom_node_purl_ref, sbom_package, sbom_package_license, source_document, status, - versioned_purl, vulnerability, + sbom_node_purl_ref, sbom_package, sbom_package_license, source_document, versioned_purl, + vulnerability, }; #[derive(Clone, Debug, Default)] @@ -1051,7 +1051,7 @@ pub struct QueryCatcher { pub advisory_vulnerability: Arc, pub vulnerability: Arc, pub context_cpe: Option>, - pub status: Arc, + pub status: String, pub organization: Option>, } @@ -1090,11 +1090,7 @@ impl FromQueryResult for QueryCatcher { )?), context_cpe: Self::from_query_result_multi_model_optional(res, "", cpe::Entity)? .map(Arc::new), - status: Arc::new(Self::from_query_result_multi_model( - res, - "", - status::Entity, - )?), + status: res.try_get("", "status")?, organization: Self::from_query_result_multi_model_optional( res, "", @@ -1116,7 +1112,6 @@ impl FromQueryResultMultiModel for QueryCatcher { .try_model_columns(qualified_purl::Entity)? .try_model_columns(sbom_package::Entity)? .try_model_columns(sbom_node::Entity)? - .try_model_columns(status::Entity)? .try_model_columns(cpe::Entity)? .try_model_columns(organization::Entity) } diff --git a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs index 9798c6583..e3eeedea3 100644 --- a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs +++ b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs @@ -27,7 +27,7 @@ use trustify_common::{ use trustify_entity::{ advisory, advisory_vulnerability, advisory_vulnerability_score, base_purl, cpe, organization, package_relates_to_package, purl_status, qualified_purl, relationship::Relationship, sbom, - sbom_node, sbom_node_purl_ref, sbom_package, status, version_range, versioned_purl, + sbom_node, sbom_node_purl_ref, sbom_package, status::Status, version_range, versioned_purl, vulnerability, }; use utoipa::ToSchema; @@ -131,7 +131,6 @@ impl VulnerabilityAdvisorySummary { tx: &C, ) -> Result, Error> { let purl_status_query = purl_status::Entity::find() - .left_join(status::Entity) .filter(purl_status::Column::VulnerabilityId.eq(&vulnerability.id)) .left_join(base_purl::Entity) .left_join(version_range::Entity) @@ -145,7 +144,6 @@ impl VulnerabilityAdvisorySummary { ) .join(JoinType::Join, versioned_purl::Relation::BasePurl.def()) .join(JoinType::Join, base_purl::Relation::PurlStatus.def()) - .join(JoinType::Join, purl_status::Relation::Status.def()) .join(JoinType::Join, purl_status::Relation::VersionRange.def()) .join(JoinType::Join, sbom_node_purl_ref::Relation::Sbom.def()) .join(JoinType::Join, sbom::Relation::SbomNode.def()) @@ -155,7 +153,7 @@ impl VulnerabilityAdvisorySummary { package_relates_to_package::Relation::RightPackage.def(), ) .filter(purl_status::Column::VulnerabilityId.eq(&vulnerability.id)) - .filter(status::Column::Slug.ne("not_affected")) + .filter(purl_status::Column::Status.ne(Status::NotAffected)) .filter(SimpleExpr::FunctionCall( Func::cust(VersionMatches) .arg(Expr::col(( @@ -202,18 +200,13 @@ impl VulnerabilityAdvisorySummary { "sbom_node"."sbom_id" AS "sbom_node$sbom_id", "sbom_node"."node_id" AS "sbom_node$node_id", "sbom_node"."name" AS "sbom_node$name", - "status"."id" AS "status$id", - "status"."slug" AS "status$slug", - "status"."name" AS "status$name", - "status"."description" AS "status$description", + "product_status"."status"::text AS "status$slug", "qualified_purl"."id" AS "qualified_purl$id", "qualified_purl"."versioned_purl_id" AS "qualified_purl$versioned_purl_id", "qualified_purl"."qualifiers" AS "qualified_purl$qualifiers", "qualified_purl"."purl" AS "qualified_purl$purl" FROM "product_status" JOIN "cpe" ON "product_status"."context_cpe_id" = "cpe"."id" - JOIN "status" ON "product_status"."status_id" = "status"."id" - -- find all related products and versions JOIN "product" ON "cpe"."product" = "product"."cpe_key" JOIN "product_version" ON "product"."id" = "product_version"."product_id" @@ -235,7 +228,7 @@ impl VulnerabilityAdvisorySummary { JOIN sbom_package on sbom_package.sbom_id = package_relates_to_package.sbom_id AND sbom_package.node_id = package_relates_to_package.right_node_id WHERE - "product_status"."vulnerability_id" = $1 AND "product_status"."package" IS NOT NULL and status.slug != 'not_affected' + "product_status"."vulnerability_id" = $1 AND "product_status"."package" IS NOT NULL and "product_status"."status" != 'not_affected' "#; let result: Vec = tx @@ -327,7 +320,7 @@ impl VulnerabilityAdvisorySummary { #[derive(Debug)] struct PurlStatusCatcher { - status: status::Model, + status_slug: String, purl_status: purl_status::Model, version_range: version_range::Model, cpe: Option, @@ -337,9 +330,12 @@ struct PurlStatusCatcher { impl FromQueryResult for PurlStatusCatcher { fn from_query_result(res: &QueryResult, _pre: &str) -> Result { + let purl_status_model: purl_status::Model = + Self::from_query_result_multi_model(res, "", purl_status::Entity)?; + let status_slug = purl_status_model.status.to_string(); Ok(Self { - status: Self::from_query_result_multi_model(res, "", status::Entity)?, - purl_status: Self::from_query_result_multi_model(res, "", purl_status::Entity)?, + status_slug, + purl_status: purl_status_model, version_range: Self::from_query_result_multi_model(res, "", version_range::Entity)?, cpe: Self::from_query_result_multi_model_optional(res, "", cpe::Entity)?, base_purl: Self::from_query_result_multi_model(res, "", base_purl::Entity)?, @@ -350,7 +346,6 @@ impl FromQueryResult for PurlStatusCatcher { impl FromQueryResultMultiModel for PurlStatusCatcher { fn try_into_multi_model(select: Select) -> Result, DbErr> { select - .try_model_columns(status::Entity)? .try_model_columns(purl_status::Entity)? .try_model_columns(version_range::Entity)? .try_model_columns(cpe::Entity)? @@ -442,7 +437,7 @@ impl VulnerabilityAdvisoryStatus { cpe.ok().map(|cpe| StatusContext::Cpe(cpe.to_string())) }); - let status_entry = statuses.entry(each.status.slug.clone()).or_insert(vec![]); + let status_entry = statuses.entry(each.status_slug.clone()).or_insert(vec![]); status_entry.push(VulnerabilityAdvisoryStatus { base_purl: BasePurlHead { uuid: each.base_purl.id, @@ -463,18 +458,28 @@ struct SbomStatusCatcher { sbom: sbom::Model, sbom_package: sbom_package::Model, sbom_node: sbom_node::Model, - status: status::Model, + status_slug: String, qualified_purl: qualified_purl::Model, } impl FromQueryResult for SbomStatusCatcher { fn from_query_result(res: &QueryResult, _pre: &str) -> Result { + // For the SeaORM query path, get status from the purl_status model + // For the raw SQL path, get status from the "status$slug" column + let status_slug: String = res + .try_get("", "status$slug") + .or_else(|_| { + // SeaORM path: extract from purl_status model + let ps: purl_status::Model = + Self::from_query_result_multi_model(res, "", purl_status::Entity)?; + Ok::(ps.status.to_string()) + })?; Ok(Self { advisory_id: res.try_get("", "advisory_id")?, sbom: Self::from_query_result_multi_model(res, "", sbom::Entity)?, sbom_package: Self::from_query_result_multi_model(res, "", sbom_package::Entity)?, sbom_node: Self::from_query_result_multi_model(res, "", sbom_node::Entity)?, - status: Self::from_query_result_multi_model(res, "", status::Entity)?, + status_slug, qualified_purl: Self::from_query_result_multi_model(res, "", qualified_purl::Entity)?, }) } @@ -486,7 +491,7 @@ impl FromQueryResultMultiModel for SbomStatusCatcher { .try_model_columns(sbom::Entity)? .try_model_columns(sbom_package::Entity)? .try_model_columns(sbom_node::Entity)? - .try_model_columns(status::Entity)? + .try_model_columns(purl_status::Entity)? .try_model_columns(base_purl::Entity)? .try_model_columns(versioned_purl::Entity)? .try_model_columns(qualified_purl::Entity) @@ -527,7 +532,7 @@ impl VulnerabilitySbomStatus { let purl_status = sbom_status .purl_statuses - .entry(status.status.slug.clone()) + .entry(status.status_slug.clone()) .or_insert(Default::default()); purl_status.insert(PurlSummary::from_entity(&status.qualified_purl)); diff --git a/modules/fundamental/src/vulnerability/service/mod.rs b/modules/fundamental/src/vulnerability/service/mod.rs index 5ab6acb01..7ac13308e 100644 --- a/modules/fundamental/src/vulnerability/service/mod.rs +++ b/modules/fundamental/src/vulnerability/service/mod.rs @@ -467,6 +467,7 @@ impl VulnerabilityService { vulnerabilities_tables: &str, conditions: &str, remediations_data_expression: &str, + status_column_expr: &str, ) -> String { format!( r#" @@ -482,7 +483,7 @@ SELECT vulnerability.id_sort_key, jsonb_agg( DISTINCT jsonb_build_object( - 'status', status.slug, + 'status', {status_column_expr}, {advisory_columns}, 'version_range', jsonb_build_object( 'version_scheme_id', version_range.version_scheme_id, @@ -507,7 +508,7 @@ SELECT ) AS advisories FROM {vulnerabilities_tables} WHERE {conditions} - AND status.slug NOT IN ( + AND {status_column_expr} NOT IN ( 'fixed', 'not_affected', 'recommended' @@ -569,7 +570,6 @@ GROUP BY LEFT JOIN purl_status ON base_purl.id = purl_status.base_purl_id INNER JOIN version_range ON purl_status.version_range_id = version_range.id LEFT JOIN vulnerability ON purl_status.vulnerability_id = vulnerability.id - INNER JOIN status ON purl_status.status_id = status.id "#, format!(r#" {ns_condition} AND base_purl.name = $2 @@ -577,6 +577,7 @@ GROUP BY AND version_matches($4, version_range.*) = TRUE "#).as_str(), "r.data", + "purl_status.status::text", ); let purl_status_query = Statement::from_sql_and_values( @@ -621,7 +622,6 @@ GROUP BY WHERE rps.product_status_id = product_status.id "#, r#" product_status - JOIN status ON product_status.status_id = status.id JOIN vulnerability ON product_status.vulnerability_id = vulnerability.id JOIN product_version_range ON product_status.product_version_range_id = product_version_range.id JOIN version_range ON product_version_range.version_range_id = version_range.id @@ -643,6 +643,7 @@ GROUP BY ) ), '[]'::jsonb) ) "#, + "product_status.status::text", ); let product_status_query = Statement::from_sql_and_values( connection.get_database_backend(), diff --git a/modules/ingestor/src/graph/advisory/advisory_vulnerability.rs b/modules/ingestor/src/graph/advisory/advisory_vulnerability.rs index 095f01449..654a4e46f 100644 --- a/modules/ingestor/src/graph/advisory/advisory_vulnerability.rs +++ b/modules/ingestor/src/graph/advisory/advisory_vulnerability.rs @@ -3,11 +3,11 @@ use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, NotSet, QueryFilter, Set, }; use sea_query::IntoCondition; +use std::str::FromStr; use tracing::instrument; use trustify_common::{cpe::Cpe, purl::Purl}; -use trustify_entity::{self as entity, purl_status, status, version_range, vulnerability}; +use trustify_entity::{self as entity, purl_status, status::Status, version_range, vulnerability}; -// Add these use statements use crate::graph::advisory::version::VersionInfo; #[derive(Clone, Debug)] @@ -51,18 +51,15 @@ impl AdvisoryVulnerabilityContext<'_> { info: VersionInfo, connection: &C, ) -> Result<(), Error> { - let status = status::Entity::find() - .filter(status::Column::Slug.eq(status)) - .one(connection) - .await? - .ok_or(Error::InvalidStatus(status.to_string()))?; + let status = + Status::from_str(status).map_err(|_| Error::InvalidStatus(status.to_string()))?; let package = self.advisory.graph.ingest_package(purl, connection).await?; let package_status = purl_status::Entity::find() .filter(purl_status::Column::BasePurlId.eq(package.base_purl.id)) .filter(purl_status::Column::AdvisoryId.eq(self.advisory.advisory.id)) - .filter(purl_status::Column::StatusId.eq(status.id)) + .filter(purl_status::Column::Status.eq(status)) .left_join(version_range::Entity) .filter(info.clone().into_condition()) .one(connection) @@ -83,7 +80,7 @@ impl AdvisoryVulnerabilityContext<'_> { id: Default::default(), advisory_id: Set(self.advisory_vulnerability.advisory_id), vulnerability_id: Set(self.advisory_vulnerability.vulnerability_id.clone()), - status_id: Set(status.id), + status: Set(status), base_purl_id: Set(package.base_purl.id), version_range_id: Set(info.clone().uuid()), context_cpe_id: NotSet, @@ -155,15 +152,6 @@ mod test { ) .await?; - /* - let affected = advisory_vulnerability - .affected_assertions(Transactional::None) - .await?; - - assert_eq!(1, affected.assertions.len()); - - */ - Ok(()) } diff --git a/modules/ingestor/src/graph/advisory/product_status.rs b/modules/ingestor/src/graph/advisory/product_status.rs index 04125f506..d3ca33f16 100644 --- a/modules/ingestor/src/graph/advisory/product_status.rs +++ b/modules/ingestor/src/graph/advisory/product_status.rs @@ -1,6 +1,6 @@ use crate::graph::advisory::version::VersionInfo; use trustify_common::cpe::Cpe; -use trustify_entity::{product_status, product_version_range, version_range}; +use trustify_entity::{product_status, product_version_range, status::Status, version_range}; use uuid::Uuid; use sea_orm::Set; @@ -56,7 +56,7 @@ impl ProductVersionRange { pub struct ProductStatus { pub cpe: Option, pub package: Option, - pub status: Uuid, + pub status: Status, pub product_version_range_id: Uuid, pub csaf_product_ids: Option>, } @@ -71,7 +71,7 @@ impl ProductStatus { id: Set(self.uuid(advisory_id, vulnerability_id.clone())), advisory_id: Set(advisory_id), vulnerability_id: Set(vulnerability_id), - status_id: Set(self.status), + status: Set(self.status), package: Set(self.package), context_cpe_id: Set(self.cpe.as_ref().map(Cpe::uuid)), product_version_range_id: Set(self.product_version_range_id), @@ -80,7 +80,7 @@ impl ProductStatus { } pub fn uuid(&self, advisory_id: Uuid, vulnerability_id: String) -> Uuid { - let mut result = Uuid::new_v5(&NAMESPACE, self.status.as_bytes()); + let mut result = Uuid::new_v5(&NAMESPACE, self.status.to_string().as_bytes()); result = Uuid::new_v5(&result, self.product_version_range_id.as_bytes()); result = Uuid::new_v5(&result, advisory_id.as_bytes()); result = Uuid::new_v5(&result, vulnerability_id.as_bytes()); diff --git a/modules/ingestor/src/graph/advisory/purl_status.rs b/modules/ingestor/src/graph/advisory/purl_status.rs index cccf22a9e..053e0a2ed 100644 --- a/modules/ingestor/src/graph/advisory/purl_status.rs +++ b/modules/ingestor/src/graph/advisory/purl_status.rs @@ -1,6 +1,6 @@ use crate::graph::advisory::version::VersionInfo; use trustify_common::{cpe::Cpe, purl::Purl}; -use trustify_entity::{purl_status, version_range}; +use trustify_entity::{purl_status, status::Status, version_range}; use uuid::Uuid; use sea_orm::Set; @@ -13,12 +13,12 @@ const NAMESPACE: Uuid = Uuid::from_bytes([ pub struct PurlStatus { pub cpe: Option, pub purl: Purl, - pub status: Uuid, + pub status: Status, pub info: VersionInfo, } impl PurlStatus { - pub fn new(cpe: Option, purl: Purl, status: Uuid, info: VersionInfo) -> Self { + pub fn new(cpe: Option, purl: Purl, status: Status, info: VersionInfo) -> Self { Self { cpe, purl, @@ -41,7 +41,7 @@ impl PurlStatus { id: Set(self.uuid(advisory_id, vulnerability_id.clone())), advisory_id: Set(advisory_id), vulnerability_id: Set(vulnerability_id), - status_id: Set(self.status), + status: Set(self.status), base_purl_id: Set(package_id), context_cpe_id: Set(cpe_id), version_range_id: version_range.clone().id, @@ -51,7 +51,7 @@ impl PurlStatus { } pub fn uuid(&self, advisory_id: Uuid, vulnerability_id: String) -> Uuid { - let mut result = Uuid::new_v5(&NAMESPACE, self.status.as_bytes()); + let mut result = Uuid::new_v5(&NAMESPACE, self.status.to_string().as_bytes()); result = Uuid::new_v5(&result, self.purl.package_uuid().as_bytes()); result = Uuid::new_v5(&result, self.info.uuid().as_bytes()); result = Uuid::new_v5(&result, advisory_id.as_bytes()); diff --git a/modules/ingestor/src/graph/db_context.rs b/modules/ingestor/src/graph/db_context.rs index e2ae3b60c..7a30cc6da 100644 --- a/modules/ingestor/src/graph/db_context.rs +++ b/modules/ingestor/src/graph/db_context.rs @@ -1,55 +1,9 @@ -use std::collections::HashMap; +use std::str::FromStr; use crate::graph::error::Error; -use sea_orm::ConnectionTrait; -use sea_orm::EntityTrait; -use trustify_entity::status; -use uuid::Uuid; +use trustify_entity::status::Status; -#[derive(Debug, Clone)] -pub struct DbContext { - pub status_cache: HashMap, -} - -impl DbContext { - pub fn new() -> Self { - Self { - status_cache: HashMap::new(), - } - } - - pub async fn load_statuses(&mut self, connection: &impl ConnectionTrait) -> Result<(), Error> { - self.status_cache.clear(); - let statuses = status::Entity::find().all(connection).await?; - statuses - .iter() - .map(|s| self.status_cache.insert(s.slug.clone(), s.id)) - .for_each(drop); - - Ok(()) - } - - pub async fn get_status_id( - &mut self, - status: &str, - connection: &impl ConnectionTrait, - ) -> Result { - if let Some(s) = self.status_cache.get(status) { - return Ok(*s); - } - - // If not found, reload the cache and check again - self.load_statuses(connection).await?; - - self.status_cache - .get(status) - .cloned() - .ok_or_else(|| crate::graph::error::Error::InvalidStatus(status.to_string())) - } -} - -impl Default for DbContext { - fn default() -> Self { - Self::new() - } +/// Parses a status slug string into a `Status` enum value. +pub fn parse_status(status: &str) -> Result { + Status::from_str(status).map_err(|_| Error::InvalidStatus(status.to_string())) } diff --git a/modules/ingestor/src/graph/mod.rs b/modules/ingestor/src/graph/mod.rs index 2a14f1606..ea60c81cd 100644 --- a/modules/ingestor/src/graph/mod.rs +++ b/modules/ingestor/src/graph/mod.rs @@ -9,7 +9,6 @@ pub mod purl; pub mod sbom; pub mod vulnerability; -use db_context::DbContext; use hex::ToHex; use sea_orm::{ ActiveValue::Set, ConnectionTrait, DbErr, EntityTrait, TransactionError, TransactionTrait, @@ -17,19 +16,15 @@ use sea_orm::{ use std::{ fmt::Debug, ops::{Deref, DerefMut}, - sync::Arc, }; use time::OffsetDateTime; -use tokio::sync::Mutex; use tracing::instrument; use trustify_common::hashing::Digests; use trustify_entity::source_document; use uuid::Uuid; #[derive(Debug, Clone)] -pub struct Graph { - pub(crate) db_context: Arc>, -} +pub struct Graph {} #[derive(Debug, thiserror::Error)] pub enum Error { @@ -41,9 +36,7 @@ pub enum Error { impl Graph { pub fn new(_db: trustify_common::db::Database) -> Self { - Self { - db_context: Arc::new(Mutex::new(DbContext::new())), - } + Self {} } /// Create a new source document, or return an existing sha256 digest if a document with that diff --git a/modules/ingestor/src/graph/purl/status_creator.rs b/modules/ingestor/src/graph/purl/status_creator.rs index adfa60acd..29cb914db 100644 --- a/modules/ingestor/src/graph/purl/status_creator.rs +++ b/modules/ingestor/src/graph/purl/status_creator.rs @@ -2,12 +2,13 @@ use crate::graph::{ advisory::{purl_status::PurlStatus, version::VersionInfo}, error::Error, }; -use sea_orm::{ActiveValue::Set, ConnectionTrait, EntityTrait, QueryFilter}; -use sea_query::{Expr, OnConflict, PgFunc}; -use std::collections::{BTreeMap, BTreeSet}; +use sea_orm::{ActiveValue::Set, ConnectionTrait, EntityTrait}; +use sea_query::OnConflict; +use std::collections::BTreeMap; +use std::str::FromStr; use tracing::instrument; use trustify_common::{cpe::Cpe, db::chunk::EntityChunkedIter, purl::Purl}; -use trustify_entity::{purl_status, status, version_range}; +use trustify_entity::{purl_status, status::Status, version_range}; use uuid::Uuid; /// Input data for creating a PURL status entry @@ -22,10 +23,6 @@ pub struct PurlStatusEntry { } /// Creator for batch insertion of PURL statuses -/// -/// Follows the Creator pattern used by PurlCreator, CpeCreator, etc. -/// Collects PURL status entries and creates them in batches to avoid -/// N+1 query problems and race conditions. #[derive(Default)] pub struct PurlStatusCreator { entries: Vec, @@ -36,12 +33,10 @@ impl PurlStatusCreator { Self::default() } - /// Add a PURL status entry to be created pub fn add(&mut self, entry: PurlStatusEntry) { self.entries.push(entry); } - /// Create all collected PURL statuses in batches #[instrument(skip_all, fields(num = self.entries.len()), err(level=tracing::Level::INFO))] pub async fn create(self, connection: &C) -> Result<(), Error> where @@ -51,40 +46,17 @@ impl PurlStatusCreator { return Ok(()); } - // 1. Batch lookup all unique status slugs - let unique_statuses: Vec = self - .entries - .iter() - .map(|e| e.status.clone()) - .collect::>() - .into_iter() - .collect(); - - let status_models = status::Entity::find() - .filter(Expr::col(status::Column::Slug).eq(PgFunc::any(unique_statuses))) - .all(connection) - .await?; - - let status_map: BTreeMap = status_models - .into_iter() - .map(|s| (s.slug.clone(), s.id)) - .collect(); - - // 2. Deduplicate and build ActiveModels let mut version_ranges = BTreeMap::new(); let mut purl_statuses = BTreeMap::new(); for entry in self.entries { - // Validate status exists - let status_id = *status_map - .get(&entry.status) - .ok_or_else(|| Error::InvalidStatus(entry.status.clone()))?; + let status = Status::from_str(&entry.status) + .map_err(|_| Error::InvalidStatus(entry.status.clone()))?; - // Create PurlStatus and use its uuid() method let purl_status = PurlStatus { cpe: entry.context_cpe.clone(), purl: entry.purl.clone(), - status: status_id, + status, info: entry.version_info.clone(), }; @@ -93,26 +65,23 @@ impl PurlStatusCreator { let version_range_id = entry.version_info.uuid(); let context_cpe_id = entry.context_cpe.as_ref().map(|cpe| cpe.uuid()); - // Deduplicate version ranges version_ranges .entry(version_range_id) .or_insert_with(|| entry.version_info.clone().into_active_model()); - // Deduplicate purl_statuses by UUID purl_statuses .entry(uuid) .or_insert_with(|| purl_status::ActiveModel { id: Set(uuid), advisory_id: Set(entry.advisory_id), vulnerability_id: Set(entry.vulnerability_id.clone()), - status_id: Set(status_id), + status: Set(status), base_purl_id: Set(base_purl_id), version_range_id: Set(version_range_id), context_cpe_id: Set(context_cpe_id), }); } - // 3. Batch insert version ranges for batch in &version_ranges.into_values().chunked() { version_range::Entity::insert_many(batch) .on_conflict(OnConflict::new().do_nothing().to_owned()) @@ -121,7 +90,6 @@ impl PurlStatusCreator { .await?; } - // 4. Batch insert purl_statuses for batch in &purl_statuses.into_values().chunked() { purl_status::Entity::insert_many(batch) .on_conflict(OnConflict::new().do_nothing().to_owned()) diff --git a/modules/ingestor/src/service/advisory/csaf/creator.rs b/modules/ingestor/src/service/advisory/csaf/creator.rs index dbb43565a..c83e1d615 100644 --- a/modules/ingestor/src/service/advisory/csaf/creator.rs +++ b/modules/ingestor/src/service/advisory/csaf/creator.rs @@ -19,7 +19,6 @@ use crate::{ use csaf::{Csaf, definitions::ProductIdT, vulnerability::Remediation}; use sea_orm::{ActiveValue::Set, ConnectionTrait, EntityTrait}; use std::collections::{HashMap, HashSet}; -use std::str::FromStr; use tracing::instrument; use trustify_common::{db::chunk::EntityChunkedIter, purl::Purl}; use trustify_entity::{ @@ -158,12 +157,7 @@ impl<'a> StatusCreator<'a> { } for product in product_statuses { - let status_id = graph - .db_context - .lock() - .await - .get_status_id(product.status, connection) - .await?; + let status = crate::graph::db_context::parse_status(product.status)?; // Organizations have been pre-ingested, just look up from cache let org_id = product @@ -219,7 +213,7 @@ impl<'a> StatusCreator<'a> { let product_status = crate::graph::advisory::product_status::ProductStatus { cpe: product.cpe.clone(), package, - status: status_id, + status, product_version_range_id: range.uuid(), csaf_product_ids: csaf_product_ids.clone(), }; @@ -253,13 +247,13 @@ impl<'a> StatusCreator<'a> { Some(version) => VersionSpec::Exact(version.clone()), None => VersionSpec::Range(Version::Unbounded, Version::Unbounded), }; - self.create_purl_status(&product, purl, scheme, spec, status_id); + self.create_purl_status(&product, purl, scheme, spec, status); // For "fixed" status and Red Hat CSAF advisories, // insert "affected" status up until this version. // Let's keep this here for now as a special case. If more exceptions arise, // we can refactor and provide support for vendor-specific parsing. - if let Ok(Status::Fixed) = Status::from_str(product.status) + if status == Status::Fixed && let Some(cpe_vendor) = product .cpe .as_ref() @@ -274,12 +268,7 @@ impl<'a> StatusCreator<'a> { purl, scheme, spec, - graph - .db_context - .lock() - .await - .get_status_id(&Status::Affected.to_string(), connection) - .await?, + Status::Affected, ); } } @@ -378,7 +367,7 @@ impl<'a> StatusCreator<'a> { purl: &Purl, scheme: VersionScheme, spec: VersionSpec, - status: Uuid, + status: Status, ) { let purl_status = PurlStatus { cpe: product.cpe.clone(), From 9f2d2ec99bc5814ddd3592244c554f119db124f5 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 19 May 2026 09:38:30 +0200 Subject: [PATCH 2/6] fix(migration): add defensive validation before status enum cast Add a pre-check assertion that verifies zero NULL status_text values remain after the data migration join, before casting to the enum type. This catches orphaned status_id references with a clear error message instead of an opaque SET NOT NULL failure. Also fixes cargo fmt formatting in csaf/creator.rs. Implements TC-4491 Assisted-by: Claude Code --- .../src/m0002200_replace_status_with_enum.rs | 24 +++++++++++++++++++ .../src/purl/model/details/versioned_purl.rs | 3 +-- .../model/details/vulnerability_advisory.rs | 14 +++++------ .../src/service/advisory/csaf/creator.rs | 8 +------ 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/migration/src/m0002200_replace_status_with_enum.rs b/migration/src/m0002200_replace_status_with_enum.rs index e6b496fe6..a9147860b 100644 --- a/migration/src/m0002200_replace_status_with_enum.rs +++ b/migration/src/m0002200_replace_status_with_enum.rs @@ -53,6 +53,30 @@ impl MigrationTrait for Migration { ) .await?; + // 2b. Validate that all rows were migrated — fail early if orphaned status_id + // references left NULL status_text values. + manager + .get_connection() + .execute_unprepared( + r#" + DO $$ + DECLARE + null_purl_count bigint; + null_product_count bigint; + BEGIN + SELECT count(*) INTO null_purl_count + FROM purl_status WHERE status_text IS NULL; + SELECT count(*) INTO null_product_count + FROM product_status WHERE status_text IS NULL; + IF null_purl_count > 0 OR null_product_count > 0 THEN + RAISE EXCEPTION 'Migration blocked: % purl_status and % product_status rows have NULL status_text (orphaned status_id references)', + null_purl_count, null_product_count; + END IF; + END$$; + "#, + ) + .await?; + // 3. Drop the old status_id FK columns (removes FK constraints on the status table) manager .get_connection() diff --git a/modules/fundamental/src/purl/model/details/versioned_purl.rs b/modules/fundamental/src/purl/model/details/versioned_purl.rs index 4177a92c3..0bac20170 100644 --- a/modules/fundamental/src/purl/model/details/versioned_purl.rs +++ b/modules/fundamental/src/purl/model/details/versioned_purl.rs @@ -10,9 +10,9 @@ use sea_orm::{ ColumnTrait, ConnectionTrait, EntityTrait, LoaderTrait, ModelTrait, QueryFilter, QuerySelect, RelationTrait, prelude::Uuid, }; -use std::collections::HashMap; use sea_query::{Asterisk, Expr, Func, JoinType, SimpleExpr}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use trustify_common::{db::VersionMatches, memo::Memo}; use trustify_entity::{ advisory, base_purl, organization, purl_status, qualified_purl, remediation, @@ -170,7 +170,6 @@ impl VersionedPurlStatus { remediations: &[remediation::Model], tx: &C, ) -> Result { - Ok(Self { vulnerability: VulnerabilityHead::from_vulnerability_entity( vuln, diff --git a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs index e3eeedea3..149e81398 100644 --- a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs +++ b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs @@ -466,14 +466,12 @@ impl FromQueryResult for SbomStatusCatcher { fn from_query_result(res: &QueryResult, _pre: &str) -> Result { // For the SeaORM query path, get status from the purl_status model // For the raw SQL path, get status from the "status$slug" column - let status_slug: String = res - .try_get("", "status$slug") - .or_else(|_| { - // SeaORM path: extract from purl_status model - let ps: purl_status::Model = - Self::from_query_result_multi_model(res, "", purl_status::Entity)?; - Ok::(ps.status.to_string()) - })?; + let status_slug: String = res.try_get("", "status$slug").or_else(|_| { + // SeaORM path: extract from purl_status model + let ps: purl_status::Model = + Self::from_query_result_multi_model(res, "", purl_status::Entity)?; + Ok::(ps.status.to_string()) + })?; Ok(Self { advisory_id: res.try_get("", "advisory_id")?, sbom: Self::from_query_result_multi_model(res, "", sbom::Entity)?, diff --git a/modules/ingestor/src/service/advisory/csaf/creator.rs b/modules/ingestor/src/service/advisory/csaf/creator.rs index c83e1d615..c7eda7aea 100644 --- a/modules/ingestor/src/service/advisory/csaf/creator.rs +++ b/modules/ingestor/src/service/advisory/csaf/creator.rs @@ -263,13 +263,7 @@ impl<'a> StatusCreator<'a> { { let spec = VersionSpec::Range(Version::Unbounded, Version::Exclusive(version.clone())); - self.create_purl_status( - &product, - purl, - scheme, - spec, - Status::Affected, - ); + self.create_purl_status(&product, purl, scheme, spec, Status::Affected); } } } From 9367d805c63e9b715ef5c1493ff287128451788a Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 19 May 2026 16:07:15 +0200 Subject: [PATCH 3/6] refactor: use Status enum instead of String in internal APIs Replace String with the Status enum type in internal struct fields and function parameters. String conversion now happens only at the API response serialization boundary, preserving type safety throughout the internal layers. Implements TC-4505 Assisted-by: Claude Code --- .../src/purl/model/details/purl.rs | 10 ++--- .../src/purl/model/details/versioned_purl.rs | 18 +++++---- modules/fundamental/src/purl/service/mod.rs | 22 +++++------ modules/fundamental/src/sbom/model/details.rs | 14 ++++--- modules/fundamental/src/sbom/service/sbom.rs | 12 +++--- .../model/details/vulnerability_advisory.rs | 37 +++++++++++-------- 6 files changed, 62 insertions(+), 51 deletions(-) diff --git a/modules/fundamental/src/purl/model/details/purl.rs b/modules/fundamental/src/purl/model/details/purl.rs index 7bf7fa67c..ad93844f1 100644 --- a/modules/fundamental/src/purl/model/details/purl.rs +++ b/modules/fundamental/src/purl/model/details/purl.rs @@ -28,8 +28,8 @@ use trustify_common::{ use trustify_entity::{ advisory, advisory_vulnerability_score, base_purl, cpe, license, organization, product, product_status, product_version, product_version_range, purl_status, qualified_purl, sbom, - sbom_license_expanded, sbom_node, sbom_node_purl_ref, sbom_package_license, version_range, - versioned_purl, vulnerability, + sbom_license_expanded, sbom_node, sbom_node_purl_ref, sbom_package_license, status::Status, + version_range, versioned_purl, vulnerability, }; use trustify_module_ingestor::common::{Deprecation, DeprecationForExt}; use utoipa::ToSchema; @@ -279,7 +279,7 @@ impl PurlAdvisory { let purl_status = PurlStatus::new( &product_status.vulnerability, &product_status.advisory, - product_status.status_slug.clone(), + product_status.status.to_string(), Some(VersionRange::from_entity(product_status.version_range)?), Some(product_status.cpe.to_string()), tx, @@ -480,7 +480,7 @@ pub struct ProductStatusCatcher { advisory: advisory::Model, vulnerability: vulnerability::Model, cpe: trustify_entity::cpe::Model, - status_slug: String, + status: Status, version_range: version_range::Model, } @@ -492,7 +492,7 @@ impl FromQueryResult for ProductStatusCatcher { advisory: Self::from_query_result_multi_model(res, "", advisory::Entity)?, vulnerability: Self::from_query_result_multi_model(res, "", vulnerability::Entity)?, cpe: Self::from_query_result_multi_model(res, "", trustify_entity::cpe::Entity)?, - status_slug: product_status_model.status.to_string(), + status: product_status_model.status, version_range: Self::from_query_result_multi_model(res, "", version_range::Entity)?, }) } diff --git a/modules/fundamental/src/purl/model/details/versioned_purl.rs b/modules/fundamental/src/purl/model/details/versioned_purl.rs index 0bac20170..9ebf67aac 100644 --- a/modules/fundamental/src/purl/model/details/versioned_purl.rs +++ b/modules/fundamental/src/purl/model/details/versioned_purl.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; use trustify_common::{db::VersionMatches, memo::Memo}; use trustify_entity::{ advisory, base_purl, organization, purl_status, qualified_purl, remediation, - remediation_purl_status, version_range, versioned_purl, vulnerability, + remediation_purl_status, status::Status, version_range, versioned_purl, vulnerability, }; use utoipa::ToSchema; @@ -128,11 +128,13 @@ impl VersionedPurlAdvisory { .zip(remediations.iter()) { if let (Some(vulnerability), Some(advisory)) = (vuln, advisory) { - let status_slug = purl_status.status.to_string(); - - let qualified_package_status = - VersionedPurlStatus::from_entity(vulnerability, status_slug, remediations, tx) - .await?; + let qualified_package_status = VersionedPurlStatus::from_entity( + vulnerability, + purl_status.status, + remediations, + tx, + ) + .await?; if let Some(entry) = results.iter_mut().find(|e| e.head.uuid == advisory.id) { entry.status.push(qualified_package_status) @@ -166,7 +168,7 @@ pub struct VersionedPurlStatus { impl VersionedPurlStatus { pub async fn from_entity( vuln: &vulnerability::Model, - status: String, + status: Status, remediations: &[remediation::Model], tx: &C, ) -> Result { @@ -177,7 +179,7 @@ impl VersionedPurlStatus { tx, ) .await?, - status, + status: status.to_string(), remediations: RemediationSummary::from_entities(remediations), }) } diff --git a/modules/fundamental/src/purl/service/mod.rs b/modules/fundamental/src/purl/service/mod.rs index 76917a82b..8d38b270e 100644 --- a/modules/fundamental/src/purl/service/mod.rs +++ b/modules/fundamental/src/purl/service/mod.rs @@ -36,7 +36,9 @@ use trustify_entity::{ advisory, base_purl, license, purl_status, qualified_purl::{self, CanonicalPurl}, remediation, remediation_purl_status, sbom_license_expanded, sbom_node, sbom_node_purl_ref, - sbom_package_license, version_range, versioned_purl, vulnerability, + sbom_package_license, + status::Status, + version_range, versioned_purl, vulnerability, }; use trustify_module_ingestor::common::Deprecation; @@ -51,7 +53,7 @@ struct PurlKey<'a> { /// Vulnerability status record linking a vulnerability ID to its VEX status and remediations. struct StatusInfo { vuln_id: String, - status_slug: String, + status: Status, remediations: Vec, /// The most recent date from the advisory that reported this status, used to pick the /// latest assessment when the same vulnerability appears in multiple advisories. @@ -628,13 +630,12 @@ impl PurlService { .zip(remediations) { if let (Some(v), Some(advisory)) = (vuln, advisory) { - let slug = ps.status.to_string(); statuses_by_base .entry(ps.base_purl_id) .or_default() .push(StatusInfo { vuln_id: v.id, - status_slug: slug, + status: ps.status, remediations: rems, advisory_date: advisory.modified.or(advisory.published), }); @@ -683,13 +684,12 @@ impl PurlService { vulnerabilities: best_by_vuln .into_values() .map(|info| { - let vex_status = match info.status_slug.as_str() { - "affected" => VexStatus::Affected, - "fixed" => VexStatus::Fixed, - "not_affected" => VexStatus::NotAffected, - "under_investigation" => VexStatus::UnderInvestigation, - "recommended" => VexStatus::Recommended, - other => VexStatus::Other(other.to_string()), + let vex_status = match info.status { + Status::Affected => VexStatus::Affected, + Status::Fixed => VexStatus::Fixed, + Status::NotAffected => VexStatus::NotAffected, + Status::UnderInvestigation => VexStatus::UnderInvestigation, + Status::Recommended => VexStatus::Recommended, }; VulnerabilityStatus { id: info.vuln_id.clone(), diff --git a/modules/fundamental/src/sbom/model/details.rs b/modules/fundamental/src/sbom/model/details.rs index 4333b9b70..4aff6b441 100644 --- a/modules/fundamental/src/sbom/model/details.rs +++ b/modules/fundamental/src/sbom/model/details.rs @@ -19,6 +19,7 @@ use sea_query::{Asterisk, Expr, ExprTrait, Func, PgFunc, SimpleExpr}; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, + str::FromStr, sync::Arc, }; use tracing::{Instrument, info_span, instrument}; @@ -26,7 +27,7 @@ use trustify_common::{db::VersionMatches, memo::Memo}; use trustify_entity::{ advisory, advisory_vulnerability, advisory_vulnerability_score, base_purl, cpe, organization, purl_status, qualified_purl, sbom, sbom_node, sbom_node_purl_ref, sbom_package, - source_document, version_range, versioned_purl, vulnerability, + source_document, status::Status, version_range, versioned_purl, vulnerability, }; use utoipa::ToSchema; use uuid::Uuid; @@ -42,7 +43,7 @@ struct IdSet { advisory_vulnerability_vulnerability_id: String, vulnerability_id: String, context_cpe_id: Option, - status: String, + status: Status, organization_id: Option, } @@ -57,7 +58,8 @@ impl FromQueryResult for IdSet { advisory_vulnerability_vulnerability_id: res.try_get("", "av_vulnerability_id")?, vulnerability_id: res.try_get("", "vulnerability_id")?, context_cpe_id: res.try_get("", "cpe_id").ok(), - status: res.try_get("", "status")?, + status: Status::from_str(&res.try_get::("", "status")?) + .map_err(|e| DbErr::Custom(e.to_string()))?, organization_id: res.try_get("", "organization_id").ok(), }) } @@ -415,7 +417,7 @@ impl SbomDetails { advisory_vulnerability: Arc::clone(advisory_vulnerability), vulnerability: Arc::clone(vulnerability), context_cpe, - status: id_set.status.clone(), + status: id_set.status, organization, }); } @@ -474,7 +476,7 @@ impl SbomAdvisory { }; let sbom_status = if let Some(status) = advisory.status.iter_mut().find(|status| { - status.status == each.status + status.status == each.status.to_string() && status.vulnerability.identifier == each.vulnerability.id }) { status @@ -482,7 +484,7 @@ impl SbomAdvisory { let status = SbomStatus::new( &each.advisory_vulnerability, &each.vulnerability, - each.status.clone(), + each.status.to_string(), status_cpe, vec![], // Look up pre-fetched scores from the map diff --git a/modules/fundamental/src/sbom/service/sbom.rs b/modules/fundamental/src/sbom/service/sbom.rs index 3fcb3fa7a..edb18d6c3 100644 --- a/modules/fundamental/src/sbom/service/sbom.rs +++ b/modules/fundamental/src/sbom/service/sbom.rs @@ -17,7 +17,7 @@ use sea_orm::{ use sea_query::{ColumnType, Expr, JoinType, UnionType, extension::postgres::PgExpr}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::{collections::HashMap, fmt::Debug, sync::Arc}; +use std::{collections::HashMap, fmt::Debug, str::FromStr, sync::Arc}; use tracing::{Instrument, info_span, instrument}; use trustify_common::{ cpe::Cpe, @@ -39,8 +39,9 @@ use trustify_entity::{ license, organization, package_relates_to_package, qualified_purl, relationship::Relationship, sbom, sbom_ai, sbom_group_assignment, sbom_license_expanded, sbom_node, sbom_node_cpe_ref, - sbom_node_purl_ref, sbom_package, sbom_package_license, source_document, versioned_purl, - vulnerability, + sbom_node_purl_ref, sbom_package, sbom_package_license, source_document, + status::Status, + versioned_purl, vulnerability, }; #[derive(Clone, Debug, Default)] @@ -1051,7 +1052,7 @@ pub struct QueryCatcher { pub advisory_vulnerability: Arc, pub vulnerability: Arc, pub context_cpe: Option>, - pub status: String, + pub status: Status, pub organization: Option>, } @@ -1090,7 +1091,8 @@ impl FromQueryResult for QueryCatcher { )?), context_cpe: Self::from_query_result_multi_model_optional(res, "", cpe::Entity)? .map(Arc::new), - status: res.try_get("", "status")?, + status: Status::from_str(&res.try_get::("", "status")?) + .map_err(|e| DbErr::Custom(e.to_string()))?, organization: Self::from_query_result_multi_model_optional( res, "", diff --git a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs index 149e81398..cd6625da3 100644 --- a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs +++ b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs @@ -13,7 +13,10 @@ use sea_orm::{ }; use sea_query::{Asterisk, Expr, Func, JoinType, SimpleExpr}; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + str::FromStr, +}; use tracing::{info_span, instrument}; use tracing_futures::Instrument; use trustify_common::{ @@ -320,7 +323,7 @@ impl VulnerabilityAdvisorySummary { #[derive(Debug)] struct PurlStatusCatcher { - status_slug: String, + status: Status, purl_status: purl_status::Model, version_range: version_range::Model, cpe: Option, @@ -332,9 +335,8 @@ impl FromQueryResult for PurlStatusCatcher { fn from_query_result(res: &QueryResult, _pre: &str) -> Result { let purl_status_model: purl_status::Model = Self::from_query_result_multi_model(res, "", purl_status::Entity)?; - let status_slug = purl_status_model.status.to_string(); Ok(Self { - status_slug, + status: purl_status_model.status, purl_status: purl_status_model, version_range: Self::from_query_result_multi_model(res, "", version_range::Entity)?, cpe: Self::from_query_result_multi_model_optional(res, "", cpe::Entity)?, @@ -437,7 +439,7 @@ impl VulnerabilityAdvisoryStatus { cpe.ok().map(|cpe| StatusContext::Cpe(cpe.to_string())) }); - let status_entry = statuses.entry(each.status_slug.clone()).or_insert(vec![]); + let status_entry = statuses.entry(each.status.to_string()).or_insert(vec![]); status_entry.push(VulnerabilityAdvisoryStatus { base_purl: BasePurlHead { uuid: each.base_purl.id, @@ -458,26 +460,29 @@ struct SbomStatusCatcher { sbom: sbom::Model, sbom_package: sbom_package::Model, sbom_node: sbom_node::Model, - status_slug: String, + status: Status, qualified_purl: qualified_purl::Model, } impl FromQueryResult for SbomStatusCatcher { fn from_query_result(res: &QueryResult, _pre: &str) -> Result { - // For the SeaORM query path, get status from the purl_status model - // For the raw SQL path, get status from the "status$slug" column - let status_slug: String = res.try_get("", "status$slug").or_else(|_| { - // SeaORM path: extract from purl_status model - let ps: purl_status::Model = - Self::from_query_result_multi_model(res, "", purl_status::Entity)?; - Ok::(ps.status.to_string()) - })?; + // For the raw SQL path, get status from the "status$slug" column as text + // For the SeaORM path, get status from the purl_status model + let status: Status = res + .try_get::("", "status$slug") + .and_then(|s| Status::from_str(&s).map_err(|e| DbErr::Custom(e.to_string()))) + .or_else(|_| { + // SeaORM path: extract from purl_status model + let ps: purl_status::Model = + Self::from_query_result_multi_model(res, "", purl_status::Entity)?; + Ok::(ps.status) + })?; Ok(Self { advisory_id: res.try_get("", "advisory_id")?, sbom: Self::from_query_result_multi_model(res, "", sbom::Entity)?, sbom_package: Self::from_query_result_multi_model(res, "", sbom_package::Entity)?, sbom_node: Self::from_query_result_multi_model(res, "", sbom_node::Entity)?, - status_slug, + status, qualified_purl: Self::from_query_result_multi_model(res, "", qualified_purl::Entity)?, }) } @@ -530,7 +535,7 @@ impl VulnerabilitySbomStatus { let purl_status = sbom_status .purl_statuses - .entry(status.status_slug.clone()) + .entry(status.status.to_string()) .or_insert(Default::default()); purl_status.insert(PurlSummary::from_entity(&status.qualified_purl)); From 59e6bc136418914ec0c6097ccdab04ad0c95cf4b Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 19 May 2026 16:14:50 +0200 Subject: [PATCH 4/6] fix(migration): implement down() for status enum migration reversibility Implement the reverse migration: recreate the status table with seed data, add status_id FK columns populated from enum values, then drop the enum columns and type. This fixes the test_migrations CI failure which requires refresh (down+up) to succeed. Implements TC-4506 Assisted-by: Claude Code --- .../src/m0002200_replace_status_with_enum.rs | 77 ++++++++++++++++++- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/migration/src/m0002200_replace_status_with_enum.rs b/migration/src/m0002200_replace_status_with_enum.rs index a9147860b..39f5ab082 100644 --- a/migration/src/m0002200_replace_status_with_enum.rs +++ b/migration/src/m0002200_replace_status_with_enum.rs @@ -140,9 +140,78 @@ impl MigrationTrait for Migration { Ok(()) } - async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { - Err(DbErr::Migration( - "Cannot reverse status table to enum migration".to_string(), - )) + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 1. Recreate the status table and seed data + manager + .get_connection() + .execute_unprepared( + r#" + CREATE TABLE IF NOT EXISTS status ( + id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + slug character varying NOT NULL, + name character varying NOT NULL, + description character varying + ); + + INSERT INTO status (id, slug, name, description) VALUES + ('85b912db-fc1b-4e75-8b27-68b68c0ed828', 'affected', 'Affected', 'Vulnerabililty affects'), + ('619aba21-abba-4220-9e3e-110cf87e5393', 'not_affected', 'Not Affected', 'Vulnerabililty does not affect'), + ('c0273e43-2b0c-4dae-a3b3-c4f9733fbfa7', 'fixed', 'Fixed', 'Vulnerabililty is fixed'), + ('23613500-86a4-4cdb-bc92-8c74e18764da', 'under_investigation', 'Under Investigation', 'Vulnerabililty is under investigation'), + ('2bb0325b-0948-44ea-bab7-46af9fc834eb', 'fixed', 'Fixed', 'Vulnerabililty is fixed'), + ('858a3f17-d864-4be8-932e-4a634de47b8b', 'recommended', 'Recommended', 'Vulnerabililty is fixed & recommended') + ON CONFLICT DO NOTHING; + "#, + ) + .await?; + + // 2. Add status_id columns and populate from the enum via slug lookup + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE purl_status + ADD COLUMN IF NOT EXISTS status_id uuid; + ALTER TABLE product_status + ADD COLUMN IF NOT EXISTS status_id uuid; + + UPDATE purl_status + SET status_id = s.id + FROM status s + WHERE s.slug = purl_status.status::text; + + UPDATE product_status + SET status_id = s.id + FROM status s + WHERE s.slug = product_status.status::text; + + ALTER TABLE purl_status + ALTER COLUMN status_id SET NOT NULL; + ALTER TABLE product_status + ALTER COLUMN status_id SET NOT NULL; + + ALTER TABLE purl_status + ADD CONSTRAINT fk_purl_status_status + FOREIGN KEY (status_id) REFERENCES status(id); + ALTER TABLE product_status + ADD CONSTRAINT fk_product_status_status + FOREIGN KEY (status_id) REFERENCES status(id); + "#, + ) + .await?; + + // 3. Drop the enum columns and enum type + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE purl_status DROP COLUMN IF EXISTS status; + ALTER TABLE product_status DROP COLUMN IF EXISTS status; + DROP TYPE IF EXISTS status; + "#, + ) + .await?; + + Ok(()) } } From ef960257ee522683c4e5686e8c67eee8448f4402 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 19 May 2026 16:21:05 +0200 Subject: [PATCH 5/6] fix: restore removed documentation and fix import style Restore doc comments on PurlStatusCreator struct and its add()/create() methods that were inadvertently removed during the status enum refactor. Import parse_status at the top of csaf/creator.rs instead of using the fully-qualified crate::graph::db_context::parse_status path. Implements TC-4507 Assisted-by: Claude Code --- modules/ingestor/src/graph/purl/status_creator.rs | 6 ++++++ modules/ingestor/src/service/advisory/csaf/creator.rs | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/ingestor/src/graph/purl/status_creator.rs b/modules/ingestor/src/graph/purl/status_creator.rs index 29cb914db..455f08f93 100644 --- a/modules/ingestor/src/graph/purl/status_creator.rs +++ b/modules/ingestor/src/graph/purl/status_creator.rs @@ -23,6 +23,10 @@ pub struct PurlStatusEntry { } /// Creator for batch insertion of PURL statuses +/// +/// Follows the Creator pattern used by PurlCreator, CpeCreator, etc. +/// Collects PURL status entries and creates them in batches to avoid +/// N+1 query problems and race conditions. #[derive(Default)] pub struct PurlStatusCreator { entries: Vec, @@ -33,10 +37,12 @@ impl PurlStatusCreator { Self::default() } + /// Add a PURL status entry to be created pub fn add(&mut self, entry: PurlStatusEntry) { self.entries.push(entry); } + /// Create all collected PURL statuses in batches #[instrument(skip_all, fields(num = self.entries.len()), err(level=tracing::Level::INFO))] pub async fn create(self, connection: &C) -> Result<(), Error> where diff --git a/modules/ingestor/src/service/advisory/csaf/creator.rs b/modules/ingestor/src/service/advisory/csaf/creator.rs index c7eda7aea..da6bc7722 100644 --- a/modules/ingestor/src/service/advisory/csaf/creator.rs +++ b/modules/ingestor/src/service/advisory/csaf/creator.rs @@ -7,6 +7,7 @@ use crate::{ version::{Version, VersionInfo, VersionSpec}, }, cpe::CpeCreator, + db_context::parse_status, organization::creator::OrganizationCreator, product::ProductInformation, purl::creator::PurlCreator, @@ -157,7 +158,7 @@ impl<'a> StatusCreator<'a> { } for product in product_statuses { - let status = crate::graph::db_context::parse_status(product.status)?; + let status = parse_status(product.status)?; // Organizations have been pre-ingested, just look up from cache let org_id = product From d418ee37ff9f48d978a7687b215224bbb1b7b1df Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 19 May 2026 16:25:07 +0200 Subject: [PATCH 6/6] fix: add serialization tests, migration comments, and UUID documentation - Add serialization alignment test verifying SeaORM, strum, and serde produce identical values for all Status variants - Document why migration uses explicit enum list (circular dependency) - Update test doc comment to reflect enum-only status values - Add SQL comment explaining raw string literal usage in enum comparison - Document UUID computation change in purl_status/product_status uuid() Implements TC-4508 Assisted-by: Claude Code --- entity/src/status.rs | 30 +++++++++++++++++++ .../src/m0002200_replace_status_with_enum.rs | 4 +++ .../fundamental/src/purl/endpoints/test.rs | 2 +- .../model/details/vulnerability_advisory.rs | 2 ++ .../src/graph/advisory/product_status.rs | 2 ++ .../src/graph/advisory/purl_status.rs | 2 ++ 6 files changed, 41 insertions(+), 1 deletion(-) diff --git a/entity/src/status.rs b/entity/src/status.rs index 771e35e71..26bbc607e 100644 --- a/entity/src/status.rs +++ b/entity/src/status.rs @@ -32,3 +32,33 @@ pub enum Status { #[sea_orm(string_value = "recommended")] Recommended, } + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::{ActiveEnum, Iterable}; + + /// Verifies that SeaORM, strum, and serde serialization values align for all Status variants. + #[test] + fn status_serialization_alignment() { + for variant in Status::iter() { + let sea_orm_value = variant.to_value(); + let strum_value = variant.to_string(); + let serde_value = serde_json::to_value(variant).unwrap(); + + assert_eq!( + sea_orm_value, strum_value, + "SeaORM and strum disagree for {:?}: sea_orm={}, strum={}", + variant, sea_orm_value, strum_value + ); + assert_eq!( + serde_value.as_str().unwrap(), + strum_value, + "serde and strum disagree for {:?}: serde={}, strum={}", + variant, + serde_value, + strum_value + ); + } + } +} diff --git a/migration/src/m0002200_replace_status_with_enum.rs b/migration/src/m0002200_replace_status_with_enum.rs index 39f5ab082..35254b762 100644 --- a/migration/src/m0002200_replace_status_with_enum.rs +++ b/migration/src/m0002200_replace_status_with_enum.rs @@ -4,6 +4,10 @@ use trustify_common::db::create_enum_if_not_exists; #[derive(DeriveMigrationName)] pub struct Migration; +// Enum variants listed explicitly because the migration crate cannot depend on +// trustify_entity::status::Status (would create a circular dependency). +// Keep in sync with entity/src/status.rs — the serialization alignment test +// in that file will catch any drift. #[derive(Clone, DeriveIden)] enum StatusEnum { #[sea_orm(iden = "status")] diff --git a/modules/fundamental/src/purl/endpoints/test.rs b/modules/fundamental/src/purl/endpoints/test.rs index ed2b23766..2647fbbd2 100644 --- a/modules/fundamental/src/purl/endpoints/test.rs +++ b/modules/fundamental/src/purl/endpoints/test.rs @@ -440,7 +440,7 @@ async fn get_recommendations_dedup(ctx: &TrustifyContext) -> Result<(), anyhow:: Ok(()) } -/// Verifies that a non-default vulnerability status is reflected in the recommendation response. +/// Verifies that overriding a vulnerability status to a different enum variant (Recommended) is reflected in the recommendation response. #[test_context(TrustifyContext)] #[test(actix_web::test)] async fn get_recommendations_other_status(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { diff --git a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs index cd6625da3..0e06a49ca 100644 --- a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs +++ b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs @@ -231,6 +231,8 @@ impl VulnerabilityAdvisorySummary { JOIN sbom_package on sbom_package.sbom_id = package_relates_to_package.sbom_id AND sbom_package.node_id = package_relates_to_package.right_node_id WHERE + -- PostgreSQL implicitly casts enum literals in comparisons; using string literal here + -- to keep the raw SQL query self-contained without Rust type references. "product_status"."vulnerability_id" = $1 AND "product_status"."package" IS NOT NULL and "product_status"."status" != 'not_affected' "#; diff --git a/modules/ingestor/src/graph/advisory/product_status.rs b/modules/ingestor/src/graph/advisory/product_status.rs index d3ca33f16..03e5f45ea 100644 --- a/modules/ingestor/src/graph/advisory/product_status.rs +++ b/modules/ingestor/src/graph/advisory/product_status.rs @@ -80,6 +80,8 @@ impl ProductStatus { } pub fn uuid(&self, advisory_id: Uuid, vulnerability_id: String) -> Uuid { + // Uses the status slug string for UUID computation. This differs from the pre-enum + // implementation which used the status UUID bytes. Re-ingestion normalizes existing rows. let mut result = Uuid::new_v5(&NAMESPACE, self.status.to_string().as_bytes()); result = Uuid::new_v5(&result, self.product_version_range_id.as_bytes()); result = Uuid::new_v5(&result, advisory_id.as_bytes()); diff --git a/modules/ingestor/src/graph/advisory/purl_status.rs b/modules/ingestor/src/graph/advisory/purl_status.rs index 053e0a2ed..ba8c7a70e 100644 --- a/modules/ingestor/src/graph/advisory/purl_status.rs +++ b/modules/ingestor/src/graph/advisory/purl_status.rs @@ -51,6 +51,8 @@ impl PurlStatus { } pub fn uuid(&self, advisory_id: Uuid, vulnerability_id: String) -> Uuid { + // Uses the status slug string for UUID computation. This differs from the pre-enum + // implementation which used the status UUID bytes. Re-ingestion normalizes existing rows. let mut result = Uuid::new_v5(&NAMESPACE, self.status.to_string().as_bytes()); result = Uuid::new_v5(&result, self.purl.package_uuid().as_bytes()); result = Uuid::new_v5(&result, self.info.uuid().as_bytes());