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..26bbc607e 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,56 @@ 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, } + +#[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/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..35254b762 --- /dev/null +++ b/migration/src/m0002200_replace_status_with_enum.rs @@ -0,0 +1,221 @@ +use sea_orm_migration::prelude::*; +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")] + 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?; + + // 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() + .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> { + // 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(()) + } +} diff --git a/modules/fundamental/src/purl/endpoints/test.rs b/modules/fundamental/src/purl/endpoints/test.rs index 028cb0d0d..2647fbbd2 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 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> { 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..ad93844f1 100644 --- a/modules/fundamental/src/purl/model/details/purl.rs +++ b/modules/fundamental/src/purl/model/details/purl.rs @@ -28,7 +28,7 @@ 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, + 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}; @@ -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.to_string(), 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: Status, 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: product_status_model.status, 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..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, status, version_range, versioned_purl, vulnerability, + remediation_purl_status, status::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,11 +128,13 @@ 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 qualified_package_status = - VersionedPurlStatus::from_entity(vulnerability, status_model, 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) @@ -176,14 +168,10 @@ pub struct VersionedPurlStatus { impl VersionedPurlStatus { pub async fn from_entity( vuln: &vulnerability::Model, - status_model: Option, + status: Status, 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( vuln, @@ -191,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 351d3fd7a..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, status, 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. @@ -612,15 +614,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,16 +630,12 @@ 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()); 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), }); @@ -695,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 b108f1be2..4aff6b441 100644 --- a/modules/fundamental/src/sbom/model/details.rs +++ b/modules/fundamental/src/sbom/model/details.rs @@ -15,10 +15,11 @@ 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}, + 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, status, 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_id: Uuid, + 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_id: res.try_get("", "status_id")?, + status: Status::from_str(&res.try_get::("", "status")?) + .map_err(|e| DbErr::Custom(e.to_string()))?, organization_id: res.try_get("", "organization_id").ok(), }) } @@ -100,7 +102,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 +113,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 +197,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 +211,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 +223,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 +315,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 +405,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 +417,7 @@ impl SbomDetails { advisory_vulnerability: Arc::clone(advisory_vulnerability), vulnerability: Arc::clone(vulnerability), context_cpe, - status: Arc::clone(status), + status: id_set.status, organization, }); } @@ -488,7 +476,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.to_string() && status.vulnerability.identifier == each.vulnerability.id }) { status @@ -496,7 +484,7 @@ impl SbomAdvisory { let status = SbomStatus::new( &each.advisory_vulnerability, &each.vulnerability, - each.status.slug.clone(), + each.status.to_string(), 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..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,7 +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, + sbom_node_purl_ref, sbom_package, sbom_package_license, source_document, + status::Status, versioned_purl, vulnerability, }; @@ -1051,7 +1052,7 @@ pub struct QueryCatcher { pub advisory_vulnerability: Arc, pub vulnerability: Arc, pub context_cpe: Option>, - pub status: Arc, + pub status: Status, pub organization: Option>, } @@ -1090,11 +1091,8 @@ 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: Status::from_str(&res.try_get::("", "status")?) + .map_err(|e| DbErr::Custom(e.to_string()))?, organization: Self::from_query_result_multi_model_optional( res, "", @@ -1116,7 +1114,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..0e06a49ca 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::{ @@ -27,7 +30,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 +134,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 +147,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 +156,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 +203,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 +231,9 @@ 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' + -- 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' "#; let result: Vec = tx @@ -327,7 +325,7 @@ impl VulnerabilityAdvisorySummary { #[derive(Debug)] struct PurlStatusCatcher { - status: status::Model, + status: Status, purl_status: purl_status::Model, version_range: version_range::Model, cpe: Option, @@ -337,9 +335,11 @@ 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)?; 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: 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)?, base_purl: Self::from_query_result_multi_model(res, "", base_purl::Entity)?, @@ -350,7 +350,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 +441,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, @@ -463,18 +462,29 @@ struct SbomStatusCatcher { sbom: sbom::Model, sbom_package: sbom_package::Model, sbom_node: sbom_node::Model, - status: status::Model, + status: Status, qualified_purl: qualified_purl::Model, } impl FromQueryResult for SbomStatusCatcher { fn from_query_result(res: &QueryResult, _pre: &str) -> Result { + // 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: Self::from_query_result_multi_model(res, "", status::Entity)?, + status, qualified_purl: Self::from_query_result_multi_model(res, "", qualified_purl::Entity)?, }) } @@ -486,7 +496,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 +537,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)); 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..03e5f45ea 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,9 @@ impl ProductStatus { } pub fn uuid(&self, advisory_id: Uuid, vulnerability_id: String) -> Uuid { - let mut result = Uuid::new_v5(&NAMESPACE, self.status.as_bytes()); + // 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()); 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..ba8c7a70e 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,9 @@ impl PurlStatus { } pub fn uuid(&self, advisory_id: Uuid, vulnerability_id: String) -> Uuid { - let mut result = Uuid::new_v5(&NAMESPACE, self.status.as_bytes()); + // 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()); 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..455f08f93 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 @@ -51,40 +52,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 +71,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 +96,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..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, @@ -19,7 +20,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 +158,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 = parse_status(product.status)?; // Organizations have been pre-ingested, just look up from cache let org_id = product @@ -219,7 +214,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 +248,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() @@ -269,18 +264,7 @@ impl<'a> StatusCreator<'a> { { let spec = VersionSpec::Range(Version::Unbounded, Version::Exclusive(version.clone())); - self.create_purl_status( - &product, - purl, - scheme, - spec, - graph - .db_context - .lock() - .await - .get_status_id(&Status::Affected.to_string(), connection) - .await?, - ); + self.create_purl_status(&product, purl, scheme, spec, Status::Affected); } } } @@ -378,7 +362,7 @@ impl<'a> StatusCreator<'a> { purl: &Purl, scheme: VersionScheme, spec: VersionSpec, - status: Uuid, + status: Status, ) { let purl_status = PurlStatus { cpe: product.cpe.clone(),