Skip to main content

termivar_scanner/
wordpress_review.rs

1//! Transport-free WordPress inventory and advisory interpretation.
2//!
3//! The types in this module describe public response hints and explicit
4//! operator declarations. They do not authenticate an installation, grant
5//! network authority, execute an advisory, or validate impact.
6
7use std::{
8    cmp::Ordering,
9    collections::{BTreeMap, BTreeSet},
10    fmt,
11    sync::Arc,
12};
13
14use serde::{
15    de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
16    Deserialize,
17};
18use serde_json::{Map, Number, Value};
19use thiserror::Error;
20use url::Url;
21
22mod asset_fingerprint;
23mod inventory;
24mod layout;
25mod wordfence_v3;
26
27pub(crate) use asset_fingerprint::valid_wordpress_asset_fingerprint_path;
28pub(crate) use wordfence_v3::MAX_WORDFENCE_V3_SOFTWARE_PER_RECORD;
29
30pub use asset_fingerprint::{
31    match_wordpress_asset_fingerprint_component, parse_wordpress_asset_fingerprint_catalog,
32    WordPressAssetFingerprintAggregateState, WordPressAssetFingerprintCatalog,
33    WordPressAssetFingerprintCatalogImport, WordPressAssetFingerprintCatalogMetadata,
34    WordPressAssetFingerprintComponent, WordPressAssetFingerprintComponentMatch,
35    WordPressAssetFingerprintError, WordPressAssetFingerprintFile, WordPressAssetFingerprintNotice,
36    WordPressAssetFingerprintProvenance, WordPressAssetFingerprintRelation,
37    WordPressAssetFingerprintRelease, WordPressAssetFingerprintReleaseMatch,
38    WordPressAssetFingerprintReleaseSource, WordPressAssetFingerprintReleaseState,
39    WordPressAssetFingerprintRepresentationProfile, WordPressAssetFingerprintResourceMatch,
40    WordPressAssetFingerprintUnknownReason, WordPressObservedAssetFingerprint,
41    MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES, MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_BYTES,
42    MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_COMPONENTS,
43    MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_FILES,
44    MAX_WORDPRESS_ASSET_FINGERPRINT_FILES_PER_RELEASE, MAX_WORDPRESS_ASSET_FINGERPRINT_PATH_BYTES,
45    MAX_WORDPRESS_ASSET_FINGERPRINT_RELEASES_PER_COMPONENT,
46    MAX_WORDPRESS_ASSET_FINGERPRINT_RESOURCES_PER_COMPONENT,
47    MAX_WORDPRESS_ASSET_FINGERPRINT_RETAINED_BYTES, WORDPRESS_ASSET_FINGERPRINT_CATALOG_SCHEMA,
48    WORDPRESS_ASSET_FINGERPRINT_REPRESENTATION_PROFILE,
49};
50
51pub use inventory::{
52    parse_wordpress_saved_inventory, WordPressInventoryCategoryStatus,
53    WordPressInventoryEntryStatus, WordPressInventoryLimitation,
54    WordPressInventoryLimitationReason, WordPressLocalInputClass, WordPressLocalInputProvenance,
55    WordPressSavedInventory, WordPressSavedInventoryCoverage, WordPressSavedInventorySummary,
56    MAX_WORDPRESS_SAVED_INVENTORY_BYTES,
57};
58pub use layout::{
59    parse_wordpress_discovery_layout, WordPressDiscoveryLayout,
60    MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES, WORDPRESS_DISCOVERY_LAYOUT_SCHEMA,
61};
62pub use wordfence_v3::{
63    parse_wordfence_v3_production, WordfenceV3AffectedRange, WordfenceV3AssociationKey,
64    WordfenceV3AssociationView, WordfenceV3Catalog, WordfenceV3Cvss, WordfenceV3CvssRating,
65    WordfenceV3Cwe, WordfenceV3IdentityCounts, WordfenceV3IdentityMapping, WordfenceV3Notice,
66    WordfenceV3ProductionError, WordfenceV3ProductionImport, WordfenceV3RangeEndpoint,
67    WordfenceV3RangeValue, WordfenceV3Record, WordfenceV3SoftwareAssociation,
68    WordfenceV3SourceComponentIdentity, WordfenceV3UnresolvedSoftwareAssociation,
69    MAX_WORDFENCE_V3_NOTICE_PARTIES, MAX_WORDFENCE_V3_PATCHED_VERSIONS,
70    MAX_WORDFENCE_V3_PRODUCTION_BYTES, MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION,
71    MAX_WORDFENCE_V3_RECORDS, MAX_WORDFENCE_V3_RECORD_BYTES, MAX_WORDFENCE_V3_REFERENCES,
72    MAX_WORDFENCE_V3_RESEARCHERS, MAX_WORDFENCE_V3_RESEARCHER_BYTES,
73    MAX_WORDFENCE_V3_RETAINED_BYTES, MAX_WORDFENCE_V3_SOFTWARE_ASSOCIATIONS,
74    WORDFENCE_V3_IDENTITY_MAPPING_POLICY, WORDFENCE_V3_MAPPING_REVISION,
75    WORDFENCE_V3_MAPPING_REVISION_V2, WORDFENCE_V3_PRODUCTION_FORMAT,
76    WORDFENCE_V3_RESOURCE_POLICY_V1, WORDFENCE_V3_RESOURCE_POLICY_V2,
77    WORDFENCE_V3_RESOURCE_POLICY_V3, WORDFENCE_V3_SOURCE_NAMESPACE,
78};
79
80use crate::wordpress_version::{
81    checked_accumulate_external_interpretation_work, ProfiledVersionError, ProfiledVersionKey,
82};
83pub use crate::wordpress_version::{
84    NumericDottedVersion, NumericDottedVersionError, PhpReleaseSubsetVersion,
85    PhpReleaseSubsetVersionError, WordPressComparisonProfile,
86};
87
88pub const WORDPRESS_CONTEXT_SCHEMA: &str = "security.wordpress-context/v1";
89pub const WORDPRESS_ADVISORY_CATALOG_SCHEMA: &str = "security.wordpress-advisory-catalog/v1";
90pub const WORDPRESS_ADVISORY_CATALOG_SCHEMA_V2: &str = "security.wordpress-advisory-catalog/v2";
91pub const MAX_WORDPRESS_CONTEXT_BYTES: usize = 1024 * 1024;
92pub const MAX_WORDPRESS_ADVISORY_CATALOG_BYTES: usize = 4 * 1024 * 1024;
93pub const MAX_WORDPRESS_CONTEXT_COMPONENTS: usize = 256;
94pub const MAX_WORDPRESS_ADVISORY_RECORDS: usize = 4_096;
95pub const MAX_WORDPRESS_RANGES_PER_ADVISORY: usize = 16;
96pub const MAX_WORDPRESS_FIXED_VERSIONS_PER_ADVISORY: usize = 16;
97pub const MAX_WORDPRESS_PREREQUISITES_PER_ADVISORY: usize = 8;
98pub const MAX_WORDPRESS_PATCH_ASSERTIONS_PER_COMPONENT: usize = 32;
99pub const MAX_WORDPRESS_SIGNALS: usize = 256;
100/// Maximum distinct components retained after merging response signals and
101/// operator-supplied context. The two independently bounded inputs may be
102/// disjoint, so this is deliberately larger than either input bound.
103pub const MAX_WORDPRESS_RESULT_COMPONENTS: usize =
104    MAX_WORDPRESS_SIGNALS + MAX_WORDPRESS_CONTEXT_COMPONENTS;
105/// Maximum version-evidence rows retained by one evaluated result. Each
106/// response signal and each context component can contribute at most one row.
107pub const MAX_WORDPRESS_RESULT_VERSION_EVIDENCE: usize =
108    MAX_WORDPRESS_SIGNALS + MAX_WORDPRESS_CONTEXT_COMPONENTS;
109pub const MAX_WORDPRESS_VERSION_COMPONENTS: usize =
110    crate::wordpress_version::MAX_WORDPRESS_VERSION_COMPONENTS;
111pub const MAX_WORDPRESS_VERSION_BYTES: usize =
112    crate::wordpress_version::MAX_WORDPRESS_VERSION_BYTES;
113pub const MAX_WORDPRESS_SLUG_BYTES: usize = 64;
114pub const MAX_WORDPRESS_IDENTIFIER_BYTES: usize = 128;
115pub const MAX_WORDPRESS_REFERENCE_BYTES: usize = 2_048;
116pub const MAX_WORDPRESS_SUMMARY_BYTES: usize = 1_024;
117pub const MAX_WORDPRESS_REMEDIATION_BYTES: usize = 2_048;
118pub const MAX_WORDPRESS_EVALUATION_WORK: usize = MAX_WORDPRESS_ADVISORY_RECORDS
119    * (MAX_WORDPRESS_RANGES_PER_ADVISORY + MAX_WORDPRESS_PREREQUISITES_PER_ADVISORY);
120pub(crate) const MAX_WORDPRESS_VERSION_RESOLUTION_WORK: usize =
121    MAX_WORDPRESS_RESULT_VERSION_EVIDENCE * 2;
122// Bound the copied external result model independently of the parser's retained
123// index. The renderer's exact encoded-byte limit remains authoritative.
124const MAX_WORDPRESS_EXTERNAL_RESULT_BYTES: usize = 16 * 1_024 * 1_024;
125/// Maximum unresolved source-identity rows copied into one rendered audit.
126///
127/// The complete unresolved count and semantic input digest still cover the
128/// full imported snapshot. This only bounds the human-readable detail
129/// projection so a complete external catalogue is not reproduced as a report.
130pub const MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS: usize = 64;
131const MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTION_BYTES: usize = 1_024 * 1_024;
132
133const MAX_CONTEXT_JSON_NODES: usize = 16_384;
134const MAX_CATALOG_JSON_NODES: usize = 262_144;
135const MAX_JSON_DEPTH: usize = 8;
136const MAX_JSON_OBJECT_FIELDS: usize = 16;
137const MAX_JSON_KEY_BYTES: usize = 64;
138const MAX_JSON_STRING_BYTES: usize = MAX_WORDPRESS_REFERENCE_BYTES;
139const DUPLICATE_KEY_MARKER: &str = "termivar-wordpress-duplicate-key";
140const JSON_LIMIT_MARKER: &str = "termivar-wordpress-json-limit";
141
142#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
143pub enum WordPressComponentKind {
144    Core,
145    Plugin,
146    Theme,
147}
148
149#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
150pub struct WordPressComponentIdentity {
151    kind: WordPressComponentKind,
152    slug: String,
153}
154
155impl WordPressComponentIdentity {
156    #[must_use]
157    pub fn core() -> Self {
158        Self {
159            kind: WordPressComponentKind::Core,
160            slug: "wordpress".to_owned(),
161        }
162    }
163
164    pub fn new(
165        kind: WordPressComponentKind,
166        slug: impl Into<String>,
167    ) -> Result<Self, WordPressReviewError> {
168        let slug = slug.into();
169        if !valid_slug(&slug)
170            || (kind == WordPressComponentKind::Core && slug != "wordpress")
171            || (kind != WordPressComponentKind::Core && slug == "wordpress")
172        {
173            return Err(WordPressReviewError::InvalidComponentIdentity);
174        }
175        Ok(Self { kind, slug })
176    }
177
178    #[must_use]
179    pub const fn kind(&self) -> WordPressComponentKind {
180        self.kind
181    }
182
183    #[must_use]
184    pub fn slug(&self) -> &str {
185        &self.slug
186    }
187}
188
189#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
190pub enum WordPressHostingOs {
191    Linux,
192    Windows,
193    Macos,
194    Bsd,
195    Other,
196}
197
198#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
199pub enum WordPressMultisiteState {
200    Enabled,
201    Disabled,
202}
203
204#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
205pub enum WordPressActivationState {
206    Active,
207    Inactive,
208    NetworkActive,
209    Unknown,
210}
211
212#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
213pub enum WordPressPatchState {
214    Applied,
215    NotApplied,
216    Unknown,
217}
218
219#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct WordPressPatchAssertion {
221    id: String,
222    state: WordPressPatchState,
223}
224
225impl WordPressPatchAssertion {
226    #[must_use]
227    pub fn id(&self) -> &str {
228        &self.id
229    }
230
231    #[must_use]
232    pub const fn state(&self) -> WordPressPatchState {
233        self.state
234    }
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub struct WordPressContextComponent {
239    identity: WordPressComponentIdentity,
240    version: Option<String>,
241    activation: Option<WordPressActivationState>,
242    patches: Vec<WordPressPatchAssertion>,
243    inventory_status: Option<WordPressInventoryEntryStatus>,
244}
245
246impl WordPressContextComponent {
247    #[must_use]
248    pub const fn identity(&self) -> &WordPressComponentIdentity {
249        &self.identity
250    }
251
252    #[must_use]
253    pub fn version(&self) -> Option<&str> {
254        self.version.as_deref()
255    }
256
257    #[must_use]
258    pub const fn activation(&self) -> Option<WordPressActivationState> {
259        self.activation
260    }
261
262    #[must_use]
263    pub fn patches(&self) -> &[WordPressPatchAssertion] {
264        &self.patches
265    }
266
267    #[must_use]
268    pub const fn inventory_status(&self) -> Option<WordPressInventoryEntryStatus> {
269        self.inventory_status
270    }
271}
272
273#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct WordPressContext {
275    root_url: Url,
276    hosting_os: Option<WordPressHostingOs>,
277    multisite: Option<WordPressMultisiteState>,
278    components: Vec<WordPressContextComponent>,
279}
280
281impl WordPressContext {
282    #[must_use]
283    pub const fn root_url(&self) -> &Url {
284        &self.root_url
285    }
286
287    #[must_use]
288    pub const fn hosting_os(&self) -> Option<WordPressHostingOs> {
289        self.hosting_os
290    }
291
292    #[must_use]
293    pub const fn multisite(&self) -> Option<WordPressMultisiteState> {
294        self.multisite
295    }
296
297    #[must_use]
298    pub fn components(&self) -> &[WordPressContextComponent] {
299        &self.components
300    }
301}
302
303#[derive(Clone, Debug, Eq, PartialEq)]
304pub struct WordPressCatalogMetadata {
305    id: String,
306    revision: String,
307    retrieved_on: String,
308}
309
310impl WordPressCatalogMetadata {
311    #[must_use]
312    pub fn id(&self) -> &str {
313        &self.id
314    }
315
316    #[must_use]
317    pub fn revision(&self) -> &str {
318        &self.revision
319    }
320
321    #[must_use]
322    pub fn retrieved_on(&self) -> &str {
323        &self.retrieved_on
324    }
325}
326
327#[derive(Clone, Debug, Eq, PartialEq)]
328pub struct WordPressAdvisorySource {
329    reference: String,
330    revision: String,
331    retrieved_on: String,
332    usage_basis: String,
333}
334
335impl WordPressAdvisorySource {
336    #[must_use]
337    pub fn reference(&self) -> &str {
338        &self.reference
339    }
340
341    #[must_use]
342    pub fn revision(&self) -> &str {
343        &self.revision
344    }
345
346    #[must_use]
347    pub fn retrieved_on(&self) -> &str {
348        &self.retrieved_on
349    }
350
351    #[must_use]
352    pub fn usage_basis(&self) -> &str {
353        &self.usage_basis
354    }
355}
356
357#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
358pub struct WordPressVersionEndpoint {
359    declared: String,
360    version: NumericDottedVersion,
361    inclusive: bool,
362}
363
364impl WordPressVersionEndpoint {
365    #[must_use]
366    pub fn declared(&self) -> &str {
367        &self.declared
368    }
369
370    #[must_use]
371    pub const fn version(&self) -> &NumericDottedVersion {
372        &self.version
373    }
374
375    #[must_use]
376    pub const fn inclusive(&self) -> bool {
377        self.inclusive
378    }
379}
380
381#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
382pub struct WordPressAffectedRange {
383    lower: Option<WordPressVersionEndpoint>,
384    upper: Option<WordPressVersionEndpoint>,
385}
386
387#[derive(Clone, Debug, Eq, PartialEq)]
388pub struct WordPressProfiledVersionEndpoint {
389    declared: String,
390    version: ProfiledVersionKey,
391    inclusive: bool,
392}
393
394impl WordPressProfiledVersionEndpoint {
395    #[must_use]
396    pub fn declared(&self) -> &str {
397        &self.declared
398    }
399
400    #[must_use]
401    pub const fn inclusive(&self) -> bool {
402        self.inclusive
403    }
404}
405
406#[derive(Clone, Debug, Eq, PartialEq)]
407pub struct WordPressProfiledAffectedRange {
408    lower: Option<WordPressProfiledVersionEndpoint>,
409    upper: Option<WordPressProfiledVersionEndpoint>,
410}
411
412impl WordPressProfiledAffectedRange {
413    #[must_use]
414    pub const fn lower(&self) -> Option<&WordPressProfiledVersionEndpoint> {
415        self.lower.as_ref()
416    }
417
418    #[must_use]
419    pub const fn upper(&self) -> Option<&WordPressProfiledVersionEndpoint> {
420        self.upper.as_ref()
421    }
422
423    fn contains(&self, version: &ProfiledVersionKey) -> Result<bool, ProfiledVersionError> {
424        let above_lower = self.lower.as_ref().is_none_or(|endpoint| {
425            endpoint.version.compare(version).is_ok_and(|ordering| {
426                ordering == Ordering::Less || (endpoint.inclusive && ordering == Ordering::Equal)
427            })
428        });
429        let below_upper = self.upper.as_ref().is_none_or(|endpoint| {
430            endpoint.version.compare(version).is_ok_and(|ordering| {
431                ordering == Ordering::Greater || (endpoint.inclusive && ordering == Ordering::Equal)
432            })
433        });
434        if self
435            .lower
436            .as_ref()
437            .is_some_and(|endpoint| endpoint.version.compare(version).is_err())
438            || self
439                .upper
440                .as_ref()
441                .is_some_and(|endpoint| endpoint.version.compare(version).is_err())
442        {
443            return Err(ProfiledVersionError);
444        }
445        Ok(above_lower && below_upper)
446    }
447}
448
449impl WordPressAffectedRange {
450    #[must_use]
451    pub const fn lower(&self) -> Option<&WordPressVersionEndpoint> {
452        self.lower.as_ref()
453    }
454
455    #[must_use]
456    pub const fn upper(&self) -> Option<&WordPressVersionEndpoint> {
457        self.upper.as_ref()
458    }
459
460    #[must_use]
461    pub fn contains(&self, version: &NumericDottedVersion) -> bool {
462        let above_lower = self.lower.as_ref().is_none_or(|endpoint| {
463            version > &endpoint.version || (endpoint.inclusive && version == &endpoint.version)
464        });
465        let below_upper = self.upper.as_ref().is_none_or(|endpoint| {
466            version < &endpoint.version || (endpoint.inclusive && version == &endpoint.version)
467        });
468        above_lower && below_upper
469    }
470}
471
472#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
473pub enum WordPressPrerequisite {
474    HostingOs {
475        equals: WordPressHostingOs,
476    },
477    Multisite {
478        equals: WordPressMultisiteState,
479    },
480    Activation {
481        equals: WordPressActivationState,
482    },
483    Patch {
484        id: String,
485        equals: WordPressPatchState,
486    },
487    Unsupported {
488        name: String,
489    },
490}
491
492#[derive(Clone, Debug, Eq, PartialEq)]
493pub struct WordPressAdvisoryRecord {
494    id: String,
495    component: WordPressComponentIdentity,
496    source: WordPressAdvisorySource,
497    cve: Option<String>,
498    summary: String,
499    affected_ranges: Vec<WordPressAffectedRange>,
500    profiled_affected_ranges: Vec<WordPressProfiledAffectedRange>,
501    comparison_profile: WordPressComparisonProfile,
502    fixed_versions: Vec<String>,
503    prerequisites: Vec<WordPressPrerequisite>,
504    remediation: Option<String>,
505}
506
507impl WordPressAdvisoryRecord {
508    #[must_use]
509    pub fn id(&self) -> &str {
510        &self.id
511    }
512
513    #[must_use]
514    pub const fn component(&self) -> &WordPressComponentIdentity {
515        &self.component
516    }
517
518    #[must_use]
519    pub const fn source(&self) -> &WordPressAdvisorySource {
520        &self.source
521    }
522
523    #[must_use]
524    pub fn cve(&self) -> Option<&str> {
525        self.cve.as_deref()
526    }
527
528    #[must_use]
529    pub fn summary(&self) -> &str {
530        &self.summary
531    }
532
533    #[must_use]
534    pub fn affected_ranges(&self) -> &[WordPressAffectedRange] {
535        &self.affected_ranges
536    }
537
538    #[must_use]
539    pub fn profiled_affected_ranges(&self) -> &[WordPressProfiledAffectedRange] {
540        &self.profiled_affected_ranges
541    }
542
543    #[must_use]
544    pub const fn comparison_profile(&self) -> WordPressComparisonProfile {
545        self.comparison_profile
546    }
547
548    #[must_use]
549    pub fn affected_range_count(&self) -> usize {
550        self.affected_ranges.len() + self.profiled_affected_ranges.len()
551    }
552
553    #[must_use]
554    pub fn compare_versions(&self, left: &str, right: &str) -> Option<Ordering> {
555        let left = ProfiledVersionKey::parse(self.comparison_profile, left).ok()?;
556        let right = ProfiledVersionKey::parse(self.comparison_profile, right).ok()?;
557        left.compare(&right).ok()
558    }
559
560    #[must_use]
561    pub fn fixed_versions(&self) -> &[String] {
562        &self.fixed_versions
563    }
564
565    #[must_use]
566    pub fn prerequisites(&self) -> &[WordPressPrerequisite] {
567        &self.prerequisites
568    }
569
570    #[must_use]
571    pub fn remediation(&self) -> Option<&str> {
572        self.remediation.as_deref()
573    }
574}
575
576#[derive(Clone, Debug, Eq, PartialEq)]
577pub struct WordPressAdvisoryCatalog {
578    schema: WordPressAdvisoryCatalogSchema,
579    metadata: WordPressCatalogMetadata,
580    records: Vec<WordPressAdvisoryRecord>,
581}
582
583impl WordPressAdvisoryCatalog {
584    #[must_use]
585    pub const fn schema(&self) -> WordPressAdvisoryCatalogSchema {
586        self.schema
587    }
588
589    #[must_use]
590    pub const fn metadata(&self) -> &WordPressCatalogMetadata {
591        &self.metadata
592    }
593
594    #[must_use]
595    pub fn records(&self) -> &[WordPressAdvisoryRecord] {
596        &self.records
597    }
598}
599
600#[derive(Clone, Copy, Debug, Eq, PartialEq)]
601pub enum WordPressAdvisoryCatalogSchema {
602    V1,
603    V2,
604}
605
606impl WordPressAdvisoryCatalogSchema {
607    #[must_use]
608    pub const fn id(self) -> &'static str {
609        match self {
610            Self::V1 => WORDPRESS_ADVISORY_CATALOG_SCHEMA,
611            Self::V2 => WORDPRESS_ADVISORY_CATALOG_SCHEMA_V2,
612        }
613    }
614}
615
616#[derive(Clone, Debug, Default, Eq, PartialEq)]
617pub struct WordPressReviewInputs {
618    context: Option<WordPressContext>,
619    catalog: Option<WordPressAdvisoryCatalog>,
620    wordfence_v3_catalog: Option<WordfenceV3Catalog>,
621    external_version_profile: Option<WordPressComparisonProfile>,
622    inventory_summary: Option<WordPressSavedInventorySummary>,
623    local_input_provenance: Vec<WordPressLocalInputProvenance>,
624    discovery_layout: Option<WordPressDiscoveryLayout>,
625    asset_fingerprint_catalog: Option<Arc<WordPressAssetFingerprintCatalogImport>>,
626}
627
628impl WordPressReviewInputs {
629    #[must_use]
630    pub const fn new(
631        context: Option<WordPressContext>,
632        catalog: Option<WordPressAdvisoryCatalog>,
633    ) -> Self {
634        Self {
635            context,
636            catalog,
637            wordfence_v3_catalog: None,
638            external_version_profile: None,
639            inventory_summary: None,
640            local_input_provenance: Vec::new(),
641            discovery_layout: None,
642            asset_fingerprint_catalog: None,
643        }
644    }
645
646    /// Builds review inputs from one validated saved-inventory selection.
647    ///
648    /// `provenance` must contain exactly one entry for every supplied inventory
649    /// category and its byte length must match the bytes parsed for that
650    /// category. Digests are retained as operator-input provenance; they are
651    /// not authentication of WP-CLI or of the selected target.
652    pub fn from_saved_inventory(
653        inventory: WordPressSavedInventory,
654        catalog: Option<WordPressAdvisoryCatalog>,
655        provenance: Vec<WordPressLocalInputProvenance>,
656    ) -> Result<Self, WordPressReviewError> {
657        let (context, inventory_summary, expected_inputs) = inventory.into_parts();
658        let local_input_provenance =
659            inventory::validate_inventory_provenance(&expected_inputs, provenance)?;
660        Ok(Self {
661            context: Some(context),
662            catalog,
663            wordfence_v3_catalog: None,
664            external_version_profile: None,
665            inventory_summary: Some(inventory_summary),
666            local_input_provenance,
667            discovery_layout: None,
668            asset_fingerprint_catalog: None,
669        })
670    }
671
672    /// Selects a validated local Wordfence V3 Production export as the
673    /// advisory input for this review.
674    ///
675    /// Native Termivar catalogues and external exports are intentionally
676    /// mutually exclusive. The export remains inert data and grants no network
677    /// or runtime authority.
678    pub fn with_wordfence_v3_catalog(
679        mut self,
680        catalog: WordfenceV3Catalog,
681    ) -> Result<Self, WordPressReviewError> {
682        if self.catalog.is_some() || self.wordfence_v3_catalog.is_some() {
683            return Err(WordPressReviewError::ConflictingAdvisoryInputs);
684        }
685        self.wordfence_v3_catalog = Some(catalog);
686        Ok(self)
687    }
688
689    /// Selects a Termivar comparison profile for a previously attached local
690    /// Wordfence V3 export.
691    ///
692    /// The profile is an explicit operator interpretation of the export's
693    /// structured bounds. It does not assert that Wordfence selected or
694    /// endorses those comparison semantics.
695    pub fn with_external_version_profile(
696        mut self,
697        profile: WordPressComparisonProfile,
698    ) -> Result<Self, WordPressReviewError> {
699        if self.wordfence_v3_catalog.is_none()
700            || self.catalog.is_some()
701            || self.external_version_profile.is_some()
702        {
703            return Err(WordPressReviewError::ExternalVersionProfileRequiresWordfenceV3);
704        }
705        self.external_version_profile = Some(profile);
706        Ok(self)
707    }
708
709    /// Attaches one validated, non-authoritative deployment declaration.
710    ///
711    /// The declaration is inert data. Runtime discovery still requires its
712    /// separate explicit opt-in and an eligible observed asset before a
713    /// component metadata request can be derived.
714    pub fn with_discovery_layout(
715        mut self,
716        layout: WordPressDiscoveryLayout,
717    ) -> Result<Self, WordPressReviewError> {
718        if self.discovery_layout.is_some() {
719            return Err(WordPressReviewError::InvalidDiscoveryLayout);
720        }
721        self.discovery_layout = Some(layout);
722        Ok(self)
723    }
724
725    /// Attaches one validated finite catalogue of exact JS/CSS reference bytes.
726    ///
727    /// The catalogue is inert local data. It neither nominates target URLs nor
728    /// grants network authority, and matching its listed releases never creates
729    /// installed-version evidence.
730    pub fn with_asset_fingerprint_catalog(
731        mut self,
732        catalog: WordPressAssetFingerprintCatalogImport,
733    ) -> Result<Self, WordPressReviewError> {
734        if self.asset_fingerprint_catalog.is_some() {
735            return Err(WordPressReviewError::InvalidAssetFingerprintCatalog);
736        }
737        self.asset_fingerprint_catalog = Some(Arc::new(catalog));
738        Ok(self)
739    }
740
741    #[must_use]
742    pub const fn context(&self) -> Option<&WordPressContext> {
743        self.context.as_ref()
744    }
745
746    #[must_use]
747    pub const fn catalog(&self) -> Option<&WordPressAdvisoryCatalog> {
748        self.catalog.as_ref()
749    }
750
751    #[must_use]
752    pub const fn wordfence_v3_catalog(&self) -> Option<&WordfenceV3Catalog> {
753        self.wordfence_v3_catalog.as_ref()
754    }
755
756    #[must_use]
757    pub const fn external_version_profile(&self) -> Option<WordPressComparisonProfile> {
758        self.external_version_profile
759    }
760
761    #[must_use]
762    pub const fn inventory_summary(&self) -> Option<&WordPressSavedInventorySummary> {
763        self.inventory_summary.as_ref()
764    }
765
766    #[must_use]
767    pub fn local_input_provenance(&self) -> &[WordPressLocalInputProvenance] {
768        &self.local_input_provenance
769    }
770
771    #[must_use]
772    pub const fn discovery_layout(&self) -> Option<&WordPressDiscoveryLayout> {
773        self.discovery_layout.as_ref()
774    }
775
776    #[must_use]
777    pub fn asset_fingerprint_catalog(&self) -> Option<&WordPressAssetFingerprintCatalogImport> {
778        self.asset_fingerprint_catalog.as_deref()
779    }
780
781    pub(crate) fn shared_asset_fingerprint_catalog(
782        &self,
783    ) -> Option<Arc<WordPressAssetFingerprintCatalogImport>> {
784        self.asset_fingerprint_catalog.clone()
785    }
786}
787
788#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
789pub enum WordPressEvidenceSource {
790    GeneratorMetadata,
791    SameOriginAssetPath,
792    ThemeStylesheetDeclaration,
793    OperatorContext,
794}
795
796#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
797pub enum WordPressEvidenceConfidence {
798    PublicDeclaration,
799    StructuralHint,
800    OperatorAssertion,
801}
802
803#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
804pub struct WordPressComponentSignal {
805    identity: WordPressComponentIdentity,
806    version: Option<String>,
807    source: WordPressEvidenceSource,
808}
809
810impl WordPressComponentSignal {
811    pub fn generator_metadata(version: Option<&str>) -> Result<Self, WordPressReviewError> {
812        let version = version.map(validate_source_version).transpose()?;
813        Ok(Self {
814            identity: WordPressComponentIdentity::core(),
815            version,
816            source: WordPressEvidenceSource::GeneratorMetadata,
817        })
818    }
819
820    pub fn same_origin_asset(
821        kind: WordPressComponentKind,
822        slug: &str,
823    ) -> Result<Self, WordPressReviewError> {
824        Ok(Self {
825            identity: WordPressComponentIdentity::new(kind, slug)?,
826            version: None,
827            source: WordPressEvidenceSource::SameOriginAssetPath,
828        })
829    }
830
831    /// Records a version declared by a fetched, exact-origin theme stylesheet.
832    /// The declaration is public artifact metadata, not authenticated install
833    /// state or an operator assertion.
834    pub fn theme_stylesheet_declaration(
835        slug: &str,
836        version: &str,
837    ) -> Result<Self, WordPressReviewError> {
838        Ok(Self {
839            identity: WordPressComponentIdentity::new(WordPressComponentKind::Theme, slug)?,
840            version: Some(validate_source_version(version)?),
841            source: WordPressEvidenceSource::ThemeStylesheetDeclaration,
842        })
843    }
844
845    /// Records a supported same-origin WordPress core asset-path hint.
846    pub fn core_asset() -> Self {
847        Self {
848            identity: WordPressComponentIdentity::core(),
849            version: None,
850            source: WordPressEvidenceSource::SameOriginAssetPath,
851        }
852    }
853
854    #[must_use]
855    pub const fn identity(&self) -> &WordPressComponentIdentity {
856        &self.identity
857    }
858
859    #[must_use]
860    pub fn version(&self) -> Option<&str> {
861        self.version.as_deref()
862    }
863
864    #[must_use]
865    pub const fn source(&self) -> WordPressEvidenceSource {
866        self.source
867    }
868
869    #[must_use]
870    pub const fn confidence(&self) -> WordPressEvidenceConfidence {
871        confidence_for_source(self.source)
872    }
873}
874
875#[derive(Clone, Copy, Debug, Eq, PartialEq)]
876pub enum WordPressComponentEvidenceClass {
877    ObservedHint,
878    OperatorSupplied,
879    Conflicting,
880    Unknown,
881}
882
883#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
884pub struct WordPressVersionEvidence {
885    value: String,
886    source: WordPressEvidenceSource,
887}
888
889impl WordPressVersionEvidence {
890    #[must_use]
891    pub fn value(&self) -> &str {
892        &self.value
893    }
894
895    #[must_use]
896    pub const fn source(&self) -> WordPressEvidenceSource {
897        self.source
898    }
899
900    #[must_use]
901    pub const fn confidence(&self) -> WordPressEvidenceConfidence {
902        confidence_for_source(self.source)
903    }
904}
905
906#[derive(Clone, Debug, Eq, PartialEq)]
907pub struct WordPressComponentAssessment {
908    identity: WordPressComponentIdentity,
909    evidence_class: WordPressComponentEvidenceClass,
910    identity_sources: Vec<WordPressEvidenceSource>,
911    confidence_classes: Vec<WordPressEvidenceConfidence>,
912    versions: Vec<WordPressVersionEvidence>,
913    activation: Option<WordPressActivationState>,
914    inventory_status: Option<WordPressInventoryEntryStatus>,
915}
916
917impl WordPressComponentAssessment {
918    #[must_use]
919    pub const fn identity(&self) -> &WordPressComponentIdentity {
920        &self.identity
921    }
922
923    #[must_use]
924    pub const fn evidence_class(&self) -> WordPressComponentEvidenceClass {
925        self.evidence_class
926    }
927
928    #[must_use]
929    pub fn identity_sources(&self) -> &[WordPressEvidenceSource] {
930        &self.identity_sources
931    }
932
933    #[must_use]
934    pub fn confidence_classes(&self) -> &[WordPressEvidenceConfidence] {
935        &self.confidence_classes
936    }
937
938    #[must_use]
939    pub fn versions(&self) -> &[WordPressVersionEvidence] {
940        &self.versions
941    }
942
943    #[must_use]
944    pub const fn activation(&self) -> Option<WordPressActivationState> {
945        self.activation
946    }
947
948    #[must_use]
949    pub const fn inventory_status(&self) -> Option<WordPressInventoryEntryStatus> {
950        self.inventory_status
951    }
952}
953
954#[derive(Clone, Copy, Debug, Eq, PartialEq)]
955pub enum WordPressCatalogStatus {
956    CatalogNotSupplied,
957    Evaluated,
958}
959
960#[derive(Clone, Copy, Debug, Eq, PartialEq)]
961pub enum WordPressVersionRelation {
962    WithinDeclaredRange,
963    OutsideDeclaredRanges,
964    Unknown,
965    Unsupported,
966}
967
968#[derive(Clone, Copy, Debug, Eq, PartialEq)]
969pub enum WordPressVersionResolution {
970    Missing,
971    SupportedEquivalent,
972    Conflicting,
973    Unsupported,
974}
975
976#[derive(Clone, Copy, Debug, Eq, PartialEq)]
977pub enum WordPressVersionResolutionReason {
978    NoVersionEvidence,
979    SingleSupportedVersion,
980    EquivalentSupportedVersions,
981    ConflictingVersionEvidence,
982    UnsupportedVersionEvidence,
983}
984
985#[derive(Clone, Copy, Debug, Eq, PartialEq)]
986pub enum WordPressPrerequisiteOutcome {
987    MatchedOnSuppliedFacts,
988    ContradictedOnSuppliedFacts,
989    Unknown,
990    Unsupported,
991}
992
993#[derive(Clone, Debug, Eq, PartialEq)]
994pub struct WordPressPrerequisiteEvaluation {
995    prerequisite: WordPressPrerequisite,
996    outcome: WordPressPrerequisiteOutcome,
997}
998
999impl WordPressPrerequisiteEvaluation {
1000    #[must_use]
1001    pub const fn prerequisite(&self) -> &WordPressPrerequisite {
1002        &self.prerequisite
1003    }
1004
1005    #[must_use]
1006    pub const fn outcome(&self) -> WordPressPrerequisiteOutcome {
1007        self.outcome
1008    }
1009}
1010
1011#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1012pub enum WordPressApplicability {
1013    CandidateMatchOnDeclaredFacts,
1014    ContradictedByDeclaredFacts,
1015    IndeterminateMissingEvidence,
1016    IndeterminateUnsupported,
1017}
1018
1019#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1020pub enum WordPressExecutionStatus {
1021    NotPerformed,
1022}
1023
1024/// The closed normalization policy for a local external advisory export.
1025///
1026/// Wordfence V3 documents structured bounds, but does not normatively define
1027/// version comparison. The unresolved variant therefore retains those bounds
1028/// without choosing either Termivar's numeric or PHP-subset comparator; the
1029/// explicit variant records a closed operator-selected Termivar rule.
1030#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1031pub enum WordPressExternalComparisonPolicy {
1032    WordfenceV3SourceSemanticsUnresolvedV1,
1033    TermivarWordfenceV3ExplicitInterpretationV1,
1034}
1035
1036impl WordPressExternalComparisonPolicy {
1037    #[must_use]
1038    pub const fn id(self) -> &'static str {
1039        match self {
1040            Self::WordfenceV3SourceSemanticsUnresolvedV1 => {
1041                "wordfence-v3/source-semantics-unresolved/v1"
1042            },
1043            Self::TermivarWordfenceV3ExplicitInterpretationV1 => {
1044                "termivar.wordfence-v3-explicit-interpretation/v1"
1045            },
1046        }
1047    }
1048}
1049
1050#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1051pub enum WordPressExternalVersionRelation {
1052    SourceComparisonSemanticsUnresolved,
1053    WithinSupportedRangeUnderSelectedPolicy,
1054    OutsideDeclaredRangesUnderSelectedPolicy,
1055    Indeterminate,
1056}
1057
1058#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1059pub enum WordPressExternalVersionRelationReason {
1060    SourceComparisonSemanticsUnresolved,
1061    ContainingRange,
1062    ContainingRangeWithPartialCoverage,
1063    AllRangesOutside,
1064    MissingVersionEvidence,
1065    ConflictingVersionEvidence,
1066    UnsupportedVersionEvidence,
1067    MissingAffectedRanges,
1068    UnsupportedAffectedRange,
1069    InvalidAffectedRange,
1070    SourcePatchedVersionWithinAffectedRange,
1071    IdentityMappingCandidate,
1072    IdentityMappingAmbiguous,
1073}
1074
1075#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1076pub enum WordPressExternalApplicability {
1077    IndeterminateUnsupported,
1078    VersionMatchUnderSelectedPolicy,
1079    NoVersionMatchUnderSelectedPolicy,
1080    Indeterminate,
1081}
1082
1083#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1084pub enum WordPressExternalRangeRelation {
1085    Contains,
1086    DoesNotContain,
1087    NotEvaluated,
1088    Unsupported,
1089    InvalidUnderProfile,
1090}
1091
1092#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1093pub enum WordPressExternalRangeReason {
1094    SelectedVersionWithinBounds,
1095    SelectedVersionOutsideBounds,
1096    MissingVersionEvidence,
1097    ConflictingVersionEvidence,
1098    UnsupportedVersionEvidence,
1099    UnsupportedLowerBound,
1100    UnsupportedUpperBound,
1101    UnsupportedBothBounds,
1102    ReversedBounds,
1103    EmptyExclusiveInterval,
1104    IdentityMappingCandidate,
1105    IdentityMappingAmbiguous,
1106}
1107
1108#[derive(Clone, Debug, Eq, PartialEq)]
1109pub struct WordPressExternalRangeEvaluation {
1110    relation: WordPressExternalRangeRelation,
1111    reason: WordPressExternalRangeReason,
1112}
1113
1114impl WordPressExternalRangeEvaluation {
1115    #[must_use]
1116    pub const fn relation(&self) -> WordPressExternalRangeRelation {
1117        self.relation
1118    }
1119
1120    #[must_use]
1121    pub const fn reason(&self) -> WordPressExternalRangeReason {
1122        self.reason
1123    }
1124}
1125
1126/// Comparator-neutral summary of the version declarations retained for one
1127/// externally evaluated component.
1128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1129pub enum WordPressExternalVersionEvidenceStatus {
1130    Missing,
1131    SingleDeclaration,
1132    RepeatedExactDeclaration,
1133    MultipleDistinctDeclarations,
1134}
1135
1136#[derive(Clone, Debug, Eq, PartialEq)]
1137pub struct WordPressExternalVersionEvidenceResolution {
1138    status: WordPressExternalVersionEvidenceStatus,
1139    evidence_row_count: usize,
1140    distinct_spelling_count: usize,
1141    semantic_status: Option<WordPressVersionResolution>,
1142    semantic_reason: Option<WordPressVersionResolutionReason>,
1143}
1144
1145impl WordPressExternalVersionEvidenceResolution {
1146    #[must_use]
1147    pub const fn status(&self) -> WordPressExternalVersionEvidenceStatus {
1148        self.status
1149    }
1150
1151    #[must_use]
1152    pub const fn evidence_row_count(&self) -> usize {
1153        self.evidence_row_count
1154    }
1155
1156    #[must_use]
1157    pub const fn distinct_spelling_count(&self) -> usize {
1158        self.distinct_spelling_count
1159    }
1160
1161    #[must_use]
1162    pub const fn semantic_status(&self) -> Option<WordPressVersionResolution> {
1163        self.semantic_status
1164    }
1165
1166    #[must_use]
1167    pub const fn semantic_reason(&self) -> Option<WordPressVersionResolutionReason> {
1168        self.semantic_reason
1169    }
1170}
1171
1172/// In-snapshot key for one upstream advisory/software source declaration.
1173///
1174/// The source-association digest disambiguates content-distinct declarations
1175/// under one advisory/component pair. It is not a stable cross-report product
1176/// identity or source authentication; semantic comparison correlates the
1177/// enclosing advisory and raw component identity separately.
1178#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
1179pub struct WordPressExternalAssociationKey {
1180    source_namespace: String,
1181    upstream_id: String,
1182    component: WordPressComponentIdentity,
1183    source_component: WordfenceV3SourceComponentIdentity,
1184    source_association_sha256: [u8; 32],
1185}
1186
1187impl WordPressExternalAssociationKey {
1188    #[must_use]
1189    pub fn source_namespace(&self) -> &str {
1190        &self.source_namespace
1191    }
1192
1193    #[must_use]
1194    pub fn upstream_id(&self) -> &str {
1195        &self.upstream_id
1196    }
1197
1198    #[must_use]
1199    pub const fn component(&self) -> &WordPressComponentIdentity {
1200        &self.component
1201    }
1202
1203    #[must_use]
1204    pub const fn source_component(&self) -> &WordfenceV3SourceComponentIdentity {
1205        &self.source_component
1206    }
1207
1208    #[must_use]
1209    pub const fn source_association_sha256(&self) -> &[u8; 32] {
1210        &self.source_association_sha256
1211    }
1212}
1213
1214#[derive(Clone, Debug, Eq, PartialEq)]
1215pub struct WordPressExternalReviewCounts {
1216    parsed_records: usize,
1217    software_associations: usize,
1218    exact_identity_associations: usize,
1219    candidate_identity_associations: usize,
1220    ambiguous_identity_associations: usize,
1221    unresolved_identity_associations: usize,
1222    projected_identity_limitations: usize,
1223    unprojected_identity_limitations: usize,
1224    selected_associations: usize,
1225    evaluable_associations: usize,
1226    unsupported_associations: usize,
1227    excluded_associations: usize,
1228    mapped_unselected_associations: usize,
1229    within_associations: usize,
1230    outside_associations: usize,
1231    indeterminate_associations: usize,
1232    selected_ranges: usize,
1233    evaluated_ranges: usize,
1234    containing_ranges: usize,
1235    noncontaining_ranges: usize,
1236    unsupported_ranges: usize,
1237    invalid_ranges: usize,
1238    not_evaluated_ranges: usize,
1239    partial_range_coverage_associations: usize,
1240}
1241
1242impl WordPressExternalReviewCounts {
1243    #[must_use]
1244    pub const fn parsed_records(&self) -> usize {
1245        self.parsed_records
1246    }
1247
1248    #[must_use]
1249    pub const fn software_associations(&self) -> usize {
1250        self.software_associations
1251    }
1252
1253    #[must_use]
1254    pub const fn exact_identity_associations(&self) -> usize {
1255        self.exact_identity_associations
1256    }
1257
1258    #[must_use]
1259    pub const fn candidate_identity_associations(&self) -> usize {
1260        self.candidate_identity_associations
1261    }
1262
1263    #[must_use]
1264    pub const fn ambiguous_identity_associations(&self) -> usize {
1265        self.ambiguous_identity_associations
1266    }
1267
1268    #[must_use]
1269    pub const fn unresolved_identity_associations(&self) -> usize {
1270        self.unresolved_identity_associations
1271    }
1272
1273    #[must_use]
1274    pub const fn projected_identity_limitations(&self) -> usize {
1275        self.projected_identity_limitations
1276    }
1277
1278    #[must_use]
1279    pub const fn unprojected_identity_limitations(&self) -> usize {
1280        self.unprojected_identity_limitations
1281    }
1282
1283    #[must_use]
1284    pub const fn selected_associations(&self) -> usize {
1285        self.selected_associations
1286    }
1287
1288    #[must_use]
1289    pub const fn evaluable_associations(&self) -> usize {
1290        self.evaluable_associations
1291    }
1292
1293    #[must_use]
1294    pub const fn unsupported_associations(&self) -> usize {
1295        self.unsupported_associations
1296    }
1297
1298    #[must_use]
1299    pub const fn excluded_associations(&self) -> usize {
1300        self.excluded_associations
1301    }
1302
1303    #[must_use]
1304    pub const fn mapped_unselected_associations(&self) -> usize {
1305        self.mapped_unselected_associations
1306    }
1307
1308    #[must_use]
1309    pub const fn within_associations(&self) -> usize {
1310        self.within_associations
1311    }
1312
1313    #[must_use]
1314    pub const fn outside_associations(&self) -> usize {
1315        self.outside_associations
1316    }
1317
1318    #[must_use]
1319    pub const fn indeterminate_associations(&self) -> usize {
1320        self.indeterminate_associations
1321    }
1322
1323    #[must_use]
1324    pub const fn selected_ranges(&self) -> usize {
1325        self.selected_ranges
1326    }
1327
1328    #[must_use]
1329    pub const fn evaluated_ranges(&self) -> usize {
1330        self.evaluated_ranges
1331    }
1332
1333    #[must_use]
1334    pub const fn containing_ranges(&self) -> usize {
1335        self.containing_ranges
1336    }
1337
1338    #[must_use]
1339    pub const fn noncontaining_ranges(&self) -> usize {
1340        self.noncontaining_ranges
1341    }
1342
1343    #[must_use]
1344    pub const fn unsupported_ranges(&self) -> usize {
1345        self.unsupported_ranges
1346    }
1347
1348    #[must_use]
1349    pub const fn invalid_ranges(&self) -> usize {
1350        self.invalid_ranges
1351    }
1352
1353    #[must_use]
1354    pub const fn not_evaluated_ranges(&self) -> usize {
1355        self.not_evaluated_ranges
1356    }
1357
1358    #[must_use]
1359    pub const fn partial_range_coverage_associations(&self) -> usize {
1360        self.partial_range_coverage_associations
1361    }
1362}
1363
1364/// One selected external advisory/software association. Provider-declared
1365/// fields are retained as inert metadata and are not converted into native
1366/// Termivar advisory authority.
1367#[derive(Clone, Debug, Eq, PartialEq)]
1368pub struct WordPressExternalAdvisoryEvaluation {
1369    key: WordPressExternalAssociationKey,
1370    identity_mapping: WordfenceV3IdentityMapping,
1371    identity_collision_raw_count: Option<usize>,
1372    title: String,
1373    informational: bool,
1374    description: String,
1375    references: Vec<String>,
1376    cwe: Option<WordfenceV3Cwe>,
1377    cvss: Option<WordfenceV3Cvss>,
1378    cve: Option<String>,
1379    cve_link: Option<String>,
1380    researchers: Vec<String>,
1381    published: Option<String>,
1382    updated: Option<String>,
1383    notice_ids: Vec<String>,
1384    display_name: String,
1385    affected_ranges: Vec<WordfenceV3AffectedRange>,
1386    range_evaluations: Option<Vec<WordPressExternalRangeEvaluation>>,
1387    source_patched: bool,
1388    source_patched_versions: Vec<String>,
1389    source_remediation: String,
1390    component_evidence: WordPressComponentEvidenceClass,
1391    version_evidence_resolution: WordPressExternalVersionEvidenceResolution,
1392    comparison_policy: WordPressExternalComparisonPolicy,
1393    version_relation: WordPressExternalVersionRelation,
1394    version_relation_reason: WordPressExternalVersionRelationReason,
1395    applicability: WordPressExternalApplicability,
1396}
1397
1398/// One structurally valid source association whose provider identity cannot be
1399/// represented by Termivar's canonical component identity. These rows remain
1400/// inert source limitations: they are preserved for review and comparison but
1401/// are never evaluated for applicability.
1402#[derive(Clone, Debug, Eq, PartialEq)]
1403pub struct WordPressExternalIdentityLimitation {
1404    upstream_id: String,
1405    source_component: WordfenceV3SourceComponentIdentity,
1406    source_association_sha256: [u8; 32],
1407    title: String,
1408    display_name: String,
1409    references: Vec<String>,
1410    affected_ranges: Vec<WordfenceV3AffectedRange>,
1411    source_patched: bool,
1412    source_patched_versions: Vec<String>,
1413    source_remediation: String,
1414    notice_ids: Vec<String>,
1415}
1416
1417impl WordPressExternalIdentityLimitation {
1418    #[must_use]
1419    pub fn upstream_id(&self) -> &str {
1420        &self.upstream_id
1421    }
1422
1423    #[must_use]
1424    pub const fn source_component(&self) -> &WordfenceV3SourceComponentIdentity {
1425        &self.source_component
1426    }
1427
1428    #[must_use]
1429    pub const fn source_association_sha256(&self) -> &[u8; 32] {
1430        &self.source_association_sha256
1431    }
1432
1433    #[must_use]
1434    pub fn title(&self) -> &str {
1435        &self.title
1436    }
1437
1438    #[must_use]
1439    pub fn display_name(&self) -> &str {
1440        &self.display_name
1441    }
1442
1443    #[must_use]
1444    pub fn references(&self) -> &[String] {
1445        &self.references
1446    }
1447
1448    #[must_use]
1449    pub fn affected_ranges(&self) -> &[WordfenceV3AffectedRange] {
1450        &self.affected_ranges
1451    }
1452
1453    #[must_use]
1454    pub const fn source_patched(&self) -> bool {
1455        self.source_patched
1456    }
1457
1458    #[must_use]
1459    pub fn source_patched_versions(&self) -> &[String] {
1460        &self.source_patched_versions
1461    }
1462
1463    #[must_use]
1464    pub fn source_remediation(&self) -> &str {
1465        &self.source_remediation
1466    }
1467
1468    #[must_use]
1469    pub fn notice_ids(&self) -> &[String] {
1470        &self.notice_ids
1471    }
1472}
1473
1474impl WordPressExternalAdvisoryEvaluation {
1475    #[must_use]
1476    pub const fn key(&self) -> &WordPressExternalAssociationKey {
1477        &self.key
1478    }
1479    #[must_use]
1480    pub const fn identity_mapping(&self) -> WordfenceV3IdentityMapping {
1481        self.identity_mapping
1482    }
1483    #[must_use]
1484    pub const fn identity_collision_raw_count(&self) -> Option<usize> {
1485        self.identity_collision_raw_count
1486    }
1487    #[must_use]
1488    pub fn title(&self) -> &str {
1489        &self.title
1490    }
1491    #[must_use]
1492    pub const fn informational(&self) -> bool {
1493        self.informational
1494    }
1495    #[must_use]
1496    pub fn description(&self) -> &str {
1497        &self.description
1498    }
1499    #[must_use]
1500    pub fn references(&self) -> &[String] {
1501        &self.references
1502    }
1503    #[must_use]
1504    pub const fn cwe(&self) -> Option<&WordfenceV3Cwe> {
1505        self.cwe.as_ref()
1506    }
1507    #[must_use]
1508    pub const fn cvss(&self) -> Option<&WordfenceV3Cvss> {
1509        self.cvss.as_ref()
1510    }
1511    #[must_use]
1512    pub fn cve(&self) -> Option<&str> {
1513        self.cve.as_deref()
1514    }
1515    #[must_use]
1516    pub fn cve_link(&self) -> Option<&str> {
1517        self.cve_link.as_deref()
1518    }
1519    #[must_use]
1520    pub fn researchers(&self) -> &[String] {
1521        &self.researchers
1522    }
1523    #[must_use]
1524    pub fn published(&self) -> Option<&str> {
1525        self.published.as_deref()
1526    }
1527    #[must_use]
1528    pub fn updated(&self) -> Option<&str> {
1529        self.updated.as_deref()
1530    }
1531    #[must_use]
1532    pub fn notice_ids(&self) -> &[String] {
1533        &self.notice_ids
1534    }
1535    #[must_use]
1536    pub fn display_name(&self) -> &str {
1537        &self.display_name
1538    }
1539    #[must_use]
1540    pub fn affected_ranges(&self) -> &[WordfenceV3AffectedRange] {
1541        &self.affected_ranges
1542    }
1543    #[must_use]
1544    pub fn range_evaluations(&self) -> Option<&[WordPressExternalRangeEvaluation]> {
1545        self.range_evaluations.as_deref()
1546    }
1547    #[must_use]
1548    pub const fn source_patched(&self) -> bool {
1549        self.source_patched
1550    }
1551    #[must_use]
1552    pub fn source_patched_versions(&self) -> &[String] {
1553        &self.source_patched_versions
1554    }
1555    #[must_use]
1556    pub fn source_remediation(&self) -> &str {
1557        &self.source_remediation
1558    }
1559    #[must_use]
1560    pub const fn component_evidence(&self) -> WordPressComponentEvidenceClass {
1561        self.component_evidence
1562    }
1563    #[must_use]
1564    pub const fn version_evidence_resolution(&self) -> &WordPressExternalVersionEvidenceResolution {
1565        &self.version_evidence_resolution
1566    }
1567    #[must_use]
1568    pub const fn comparison_policy(&self) -> WordPressExternalComparisonPolicy {
1569        self.comparison_policy
1570    }
1571    #[must_use]
1572    pub const fn version_relation(&self) -> WordPressExternalVersionRelation {
1573        self.version_relation
1574    }
1575    #[must_use]
1576    pub const fn version_relation_reason(&self) -> WordPressExternalVersionRelationReason {
1577        self.version_relation_reason
1578    }
1579    #[must_use]
1580    pub const fn applicability(&self) -> WordPressExternalApplicability {
1581        self.applicability
1582    }
1583    #[must_use]
1584    pub const fn exploit_execution(&self) -> WordPressExecutionStatus {
1585        WordPressExecutionStatus::NotPerformed
1586    }
1587    #[must_use]
1588    pub const fn impact_validation(&self) -> WordPressExecutionStatus {
1589        WordPressExecutionStatus::NotPerformed
1590    }
1591}
1592
1593#[derive(Clone, Debug, Eq, PartialEq)]
1594pub struct WordPressExternalReview {
1595    byte_length: u64,
1596    sha256: [u8; 32],
1597    semantic_sha256: [u8; 32],
1598    retained_bytes: usize,
1599    mapping_revision: &'static str,
1600    resource_policy: &'static str,
1601    identity_counts: WordfenceV3IdentityCounts,
1602    comparison_policy: WordPressExternalComparisonPolicy,
1603    comparison_profile: Option<WordPressComparisonProfile>,
1604    counts: WordPressExternalReviewCounts,
1605    notices: Vec<WordfenceV3Notice>,
1606    evaluations: Vec<WordPressExternalAdvisoryEvaluation>,
1607    identity_limitations: Vec<WordPressExternalIdentityLimitation>,
1608}
1609
1610impl WordPressExternalReview {
1611    #[must_use]
1612    pub const fn source_namespace(&self) -> &'static str {
1613        WORDFENCE_V3_SOURCE_NAMESPACE
1614    }
1615    #[must_use]
1616    pub const fn source_format(&self) -> &'static str {
1617        WORDFENCE_V3_PRODUCTION_FORMAT
1618    }
1619    #[must_use]
1620    pub const fn mapping_revision(&self) -> &'static str {
1621        self.mapping_revision
1622    }
1623    #[must_use]
1624    pub const fn identity_mapping_policy(&self) -> &'static str {
1625        WORDFENCE_V3_IDENTITY_MAPPING_POLICY
1626    }
1627    #[must_use]
1628    pub const fn identity_source_assurance(&self) -> &'static str {
1629        "not_established"
1630    }
1631    #[must_use]
1632    pub const fn resource_policy(&self) -> &'static str {
1633        self.resource_policy
1634    }
1635    #[must_use]
1636    pub const fn identity_counts(&self) -> &WordfenceV3IdentityCounts {
1637        &self.identity_counts
1638    }
1639    #[must_use]
1640    pub fn requires_audit_v6(&self) -> bool {
1641        self.mapping_revision == WORDFENCE_V3_MAPPING_REVISION_V2
1642            || self.resource_policy != WORDFENCE_V3_RESOURCE_POLICY_V1
1643    }
1644    #[must_use]
1645    pub const fn byte_length(&self) -> u64 {
1646        self.byte_length
1647    }
1648    #[must_use]
1649    pub const fn sha256(&self) -> &[u8; 32] {
1650        &self.sha256
1651    }
1652    #[must_use]
1653    pub const fn semantic_sha256(&self) -> &[u8; 32] {
1654        &self.semantic_sha256
1655    }
1656    #[must_use]
1657    pub const fn retained_bytes(&self) -> usize {
1658        self.retained_bytes
1659    }
1660    #[must_use]
1661    pub const fn comparison_policy(&self) -> WordPressExternalComparisonPolicy {
1662        self.comparison_policy
1663    }
1664    #[must_use]
1665    pub const fn comparison_profile(&self) -> Option<WordPressComparisonProfile> {
1666        self.comparison_profile
1667    }
1668    #[must_use]
1669    pub const fn counts(&self) -> &WordPressExternalReviewCounts {
1670        &self.counts
1671    }
1672    #[must_use]
1673    pub fn notices(&self) -> &[WordfenceV3Notice] {
1674        &self.notices
1675    }
1676    #[must_use]
1677    pub fn evaluations(&self) -> &[WordPressExternalAdvisoryEvaluation] {
1678        &self.evaluations
1679    }
1680
1681    #[must_use]
1682    pub fn identity_limitations(&self) -> &[WordPressExternalIdentityLimitation] {
1683        &self.identity_limitations
1684    }
1685}
1686
1687#[derive(Clone, Debug, Eq, PartialEq)]
1688pub struct WordPressAdvisoryEvaluation {
1689    record: WordPressAdvisoryRecord,
1690    component_evidence: WordPressComponentEvidenceClass,
1691    version_relation: WordPressVersionRelation,
1692    version_resolution: Option<WordPressVersionResolution>,
1693    version_resolution_reason: Option<WordPressVersionResolutionReason>,
1694    prerequisites: Vec<WordPressPrerequisiteEvaluation>,
1695    applicability: WordPressApplicability,
1696}
1697
1698impl WordPressAdvisoryEvaluation {
1699    #[must_use]
1700    pub const fn record(&self) -> &WordPressAdvisoryRecord {
1701        &self.record
1702    }
1703
1704    #[must_use]
1705    pub const fn component_evidence(&self) -> WordPressComponentEvidenceClass {
1706        self.component_evidence
1707    }
1708
1709    #[must_use]
1710    pub const fn version_relation(&self) -> WordPressVersionRelation {
1711        self.version_relation
1712    }
1713
1714    #[must_use]
1715    pub const fn version_resolution(&self) -> Option<WordPressVersionResolution> {
1716        self.version_resolution
1717    }
1718
1719    #[must_use]
1720    pub const fn version_resolution_reason(&self) -> Option<WordPressVersionResolutionReason> {
1721        self.version_resolution_reason
1722    }
1723
1724    #[must_use]
1725    pub fn prerequisites(&self) -> &[WordPressPrerequisiteEvaluation] {
1726        &self.prerequisites
1727    }
1728
1729    #[must_use]
1730    pub const fn applicability(&self) -> WordPressApplicability {
1731        self.applicability
1732    }
1733
1734    #[must_use]
1735    pub const fn exploit_execution(&self) -> WordPressExecutionStatus {
1736        WordPressExecutionStatus::NotPerformed
1737    }
1738
1739    #[must_use]
1740    pub const fn impact_validation(&self) -> WordPressExecutionStatus {
1741        WordPressExecutionStatus::NotPerformed
1742    }
1743}
1744
1745#[derive(Clone, Debug, Eq, PartialEq)]
1746pub struct WordPressReviewResult {
1747    catalog_status: WordPressCatalogStatus,
1748    catalog_schema: Option<WordPressAdvisoryCatalogSchema>,
1749    catalog_metadata: Option<WordPressCatalogMetadata>,
1750    components: Vec<WordPressComponentAssessment>,
1751    advisories: Vec<WordPressAdvisoryEvaluation>,
1752    external_review: Option<WordPressExternalReview>,
1753    inventory_summary: Option<WordPressSavedInventorySummary>,
1754    local_input_provenance: Vec<WordPressLocalInputProvenance>,
1755}
1756
1757impl WordPressReviewResult {
1758    #[must_use]
1759    pub const fn catalog_status(&self) -> WordPressCatalogStatus {
1760        self.catalog_status
1761    }
1762
1763    #[must_use]
1764    pub const fn catalog_schema(&self) -> Option<WordPressAdvisoryCatalogSchema> {
1765        self.catalog_schema
1766    }
1767
1768    #[must_use]
1769    pub const fn catalog_metadata(&self) -> Option<&WordPressCatalogMetadata> {
1770        self.catalog_metadata.as_ref()
1771    }
1772
1773    #[must_use]
1774    pub fn components(&self) -> &[WordPressComponentAssessment] {
1775        &self.components
1776    }
1777
1778    #[must_use]
1779    pub fn advisories(&self) -> &[WordPressAdvisoryEvaluation] {
1780        &self.advisories
1781    }
1782
1783    #[must_use]
1784    pub const fn external_review(&self) -> Option<&WordPressExternalReview> {
1785        self.external_review.as_ref()
1786    }
1787
1788    #[must_use]
1789    pub const fn inventory_summary(&self) -> Option<&WordPressSavedInventorySummary> {
1790        self.inventory_summary.as_ref()
1791    }
1792
1793    #[must_use]
1794    pub fn local_input_provenance(&self) -> &[WordPressLocalInputProvenance] {
1795        &self.local_input_provenance
1796    }
1797}
1798
1799#[derive(Clone, Debug, Eq, Error, PartialEq)]
1800pub enum WordPressReviewError {
1801    #[error("WordPress context exceeds its byte limit")]
1802    ContextTooLarge,
1803    #[error("WordPress advisory catalog exceeds its byte limit")]
1804    CatalogTooLarge,
1805    #[error("WordPress discovery layout exceeds its byte limit")]
1806    DiscoveryLayoutTooLarge,
1807    #[error("saved WordPress inventory exceeds its aggregate byte limit")]
1808    InventoryTooLarge,
1809    #[error("WordPress input is empty")]
1810    EmptyInput,
1811    #[error("WordPress input contains a duplicate object key")]
1812    DuplicateKey,
1813    #[error("WordPress input exceeds a structural limit")]
1814    JsonLimitExceeded,
1815    #[error("WordPress input is not valid bounded JSON")]
1816    MalformedJson,
1817    #[error("WordPress input uses an unsupported schema")]
1818    UnsupportedSchema,
1819    #[error("WordPress context is invalid")]
1820    InvalidContext,
1821    #[error("saved WordPress inventory is invalid")]
1822    InvalidInventory,
1823    #[error("WordPress discovery layout is invalid")]
1824    InvalidDiscoveryLayout,
1825    #[error("saved WordPress inventory exceeds its component limit")]
1826    InventoryComponentLimitExceeded,
1827    #[error("saved WordPress inventory provenance does not match its supplied inputs")]
1828    InvalidInventoryProvenance,
1829    #[error("WordPress advisory catalog is invalid")]
1830    InvalidCatalog,
1831    #[error("WordPress asset fingerprint catalogue selection is invalid")]
1832    InvalidAssetFingerprintCatalog,
1833    #[error("native and external WordPress advisory inputs cannot be combined")]
1834    ConflictingAdvisoryInputs,
1835    #[error("an external version profile requires a local Wordfence V3 advisory export")]
1836    ExternalVersionProfileRequiresWordfenceV3,
1837    #[error("external WordPress advisory data cannot be represented within review limits")]
1838    InvalidExternalCatalog,
1839    #[error("WordPress component identity is invalid")]
1840    InvalidComponentIdentity,
1841    #[error("WordPress input repeats a component identity")]
1842    DuplicateComponent,
1843    #[error("WordPress advisory catalog repeats a record identity")]
1844    DuplicateAdvisory,
1845    #[error("WordPress advisory contains an invalid affected range")]
1846    InvalidVersionRange,
1847    #[error("WordPress response signal limit exceeded")]
1848    SignalLimitExceeded,
1849    #[error("WordPress discovery candidate identity accounting limit exceeded")]
1850    DiscoveryCandidateIdentityLimitExceeded,
1851    #[error("WordPress review result limit exceeded")]
1852    ResultLimitExceeded,
1853    #[error("WordPress advisory evaluation work limit exceeded")]
1854    EvaluationLimitExceeded,
1855}
1856
1857pub fn parse_wordpress_context(bytes: &[u8]) -> Result<WordPressContext, WordPressReviewError> {
1858    let value = parse_bounded_json(
1859        bytes,
1860        MAX_WORDPRESS_CONTEXT_BYTES,
1861        MAX_CONTEXT_JSON_NODES,
1862        MAX_WORDPRESS_CONTEXT_COMPONENTS,
1863        WordPressReviewError::ContextTooLarge,
1864    )?;
1865    require_schema(&value, WORDPRESS_CONTEXT_SCHEMA)?;
1866    let wire: ContextWire =
1867        serde_json::from_value(value).map_err(|_| WordPressReviewError::InvalidContext)?;
1868    let root_url = validate_root_url(&wire.root)?;
1869    if wire.components.len() > MAX_WORDPRESS_CONTEXT_COMPONENTS {
1870        return Err(WordPressReviewError::InvalidContext);
1871    }
1872    let mut identities = BTreeSet::new();
1873    let mut components = Vec::with_capacity(wire.components.len());
1874    for component in wire.components {
1875        let identity = WordPressComponentIdentity::new(component.kind.into(), component.slug)?;
1876        if !identities.insert(identity.clone()) {
1877            return Err(WordPressReviewError::DuplicateComponent);
1878        }
1879        let version = component
1880            .version
1881            .as_deref()
1882            .map(validate_source_version)
1883            .transpose()?;
1884        let patches = validate_patches(component.patches)?;
1885        components.push(WordPressContextComponent {
1886            identity,
1887            version,
1888            activation: component.activation.map(Into::into),
1889            patches,
1890            inventory_status: None,
1891        });
1892    }
1893    components.sort_by(|left, right| left.identity.cmp(&right.identity));
1894    Ok(WordPressContext {
1895        root_url,
1896        hosting_os: wire.hosting_os.map(Into::into),
1897        multisite: wire.multisite.map(Into::into),
1898        components,
1899    })
1900}
1901
1902pub fn parse_wordpress_advisory_catalog(
1903    bytes: &[u8],
1904) -> Result<WordPressAdvisoryCatalog, WordPressReviewError> {
1905    let value = parse_bounded_json(
1906        bytes,
1907        MAX_WORDPRESS_ADVISORY_CATALOG_BYTES,
1908        MAX_CATALOG_JSON_NODES,
1909        MAX_WORDPRESS_ADVISORY_RECORDS,
1910        WordPressReviewError::CatalogTooLarge,
1911    )?;
1912    let schema = match value.get("schema").and_then(Value::as_str) {
1913        Some(WORDPRESS_ADVISORY_CATALOG_SCHEMA) => WordPressAdvisoryCatalogSchema::V1,
1914        Some(WORDPRESS_ADVISORY_CATALOG_SCHEMA_V2) => WordPressAdvisoryCatalogSchema::V2,
1915        _ => return Err(WordPressReviewError::UnsupportedSchema),
1916    };
1917    let (catalog, records) = match schema {
1918        WordPressAdvisoryCatalogSchema::V1 => {
1919            let wire: CatalogV1Wire =
1920                serde_json::from_value(value).map_err(|_| WordPressReviewError::InvalidCatalog)?;
1921            (
1922                wire.catalog,
1923                wire.records
1924                    .into_iter()
1925                    .map(AdvisoryRecordInput::from)
1926                    .collect::<Vec<_>>(),
1927            )
1928        },
1929        WordPressAdvisoryCatalogSchema::V2 => {
1930            let wire: CatalogV2Wire =
1931                serde_json::from_value(value).map_err(|_| WordPressReviewError::InvalidCatalog)?;
1932            (
1933                wire.catalog,
1934                wire.records
1935                    .into_iter()
1936                    .map(AdvisoryRecordInput::try_from)
1937                    .collect::<Result<Vec<_>, _>>()?,
1938            )
1939        },
1940    };
1941    validate_identifier(&catalog.id)?;
1942    validate_identifier(&catalog.revision)?;
1943    validate_date(&catalog.retrieved_on)?;
1944    if records.len() > MAX_WORDPRESS_ADVISORY_RECORDS {
1945        return Err(WordPressReviewError::InvalidCatalog);
1946    }
1947    let metadata = WordPressCatalogMetadata {
1948        id: catalog.id,
1949        revision: catalog.revision,
1950        retrieved_on: catalog.retrieved_on,
1951    };
1952    let mut record_ids = BTreeSet::new();
1953    let mut validated_records = Vec::with_capacity(records.len());
1954    let mut work = 0_usize;
1955    for record in records {
1956        validate_identifier(&record.id)?;
1957        if !record_ids.insert(record.id.clone()) {
1958            return Err(WordPressReviewError::DuplicateAdvisory);
1959        }
1960        let component =
1961            WordPressComponentIdentity::new(record.component.kind.into(), record.component.slug)?;
1962        let source = validate_source(record.source)?;
1963        if let Some(cve) = record.cve.as_deref() {
1964            if !valid_cve(cve) {
1965                return Err(WordPressReviewError::InvalidCatalog);
1966            }
1967        }
1968        validate_text(&record.summary, MAX_WORDPRESS_SUMMARY_BYTES)?;
1969        if record.affected_ranges.len() > MAX_WORDPRESS_RANGES_PER_ADVISORY
1970            || record.fixed_versions.len() > MAX_WORDPRESS_FIXED_VERSIONS_PER_ADVISORY
1971            || record.prerequisites.len() > MAX_WORDPRESS_PREREQUISITES_PER_ADVISORY
1972        {
1973            return Err(WordPressReviewError::InvalidCatalog);
1974        }
1975        work = work
1976            .checked_add(record.affected_ranges.len())
1977            .and_then(|value| value.checked_add(record.prerequisites.len()))
1978            .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
1979        if work > MAX_WORDPRESS_EVALUATION_WORK {
1980            return Err(WordPressReviewError::EvaluationLimitExceeded);
1981        }
1982        let (affected_ranges, profiled_affected_ranges) = match schema {
1983            WordPressAdvisoryCatalogSchema::V1 => {
1984                let mut ranges = record
1985                    .affected_ranges
1986                    .into_iter()
1987                    .map(validate_range)
1988                    .collect::<Result<Vec<_>, _>>()?;
1989                ranges.sort();
1990                ranges.dedup();
1991                (ranges, Vec::new())
1992            },
1993            WordPressAdvisoryCatalogSchema::V2 => {
1994                let mut ranges = record
1995                    .affected_ranges
1996                    .into_iter()
1997                    .map(|range| validate_profiled_range(range, record.comparison_profile))
1998                    .collect::<Result<Vec<_>, _>>()?;
1999                ranges.sort_by(compare_profiled_ranges);
2000                ranges.dedup_by(|left, right| profiled_ranges_equivalent(left, right));
2001                (Vec::new(), ranges)
2002            },
2003        };
2004        let mut fixed_versions: Vec<(ProfiledVersionKey, String)> =
2005            Vec::with_capacity(record.fixed_versions.len());
2006        for fixed in record.fixed_versions {
2007            let parsed = ProfiledVersionKey::parse(record.comparison_profile, &fixed)
2008                .map_err(|_| WordPressReviewError::InvalidVersionRange)?;
2009            if fixed_versions
2010                .iter()
2011                .any(|(existing, _)| existing.compare(&parsed).ok() == Some(Ordering::Equal))
2012            {
2013                return Err(WordPressReviewError::InvalidCatalog);
2014            }
2015            fixed_versions.push((parsed, fixed));
2016        }
2017        fixed_versions.sort_by(|left, right| {
2018            left.0
2019                .compare(&right.0)
2020                .expect("one record has exactly one validated comparison profile")
2021                .then_with(|| left.1.cmp(&right.1))
2022        });
2023        let fixed_versions = fixed_versions
2024            .into_iter()
2025            .map(|(_, declared)| declared)
2026            .collect();
2027        let mut prerequisites = record
2028            .prerequisites
2029            .into_iter()
2030            .map(validate_prerequisite)
2031            .collect::<Result<Vec<_>, _>>()?;
2032        prerequisites.sort();
2033        if prerequisites.windows(2).any(|pair| pair[0] == pair[1]) {
2034            return Err(WordPressReviewError::InvalidCatalog);
2035        }
2036        let remediation = record
2037            .remediation
2038            .map(|value| {
2039                validate_text(&value, MAX_WORDPRESS_REMEDIATION_BYTES)?;
2040                Ok(value)
2041            })
2042            .transpose()?;
2043        validated_records.push(WordPressAdvisoryRecord {
2044            id: record.id,
2045            component,
2046            source,
2047            cve: record.cve,
2048            summary: record.summary,
2049            affected_ranges,
2050            profiled_affected_ranges,
2051            comparison_profile: record.comparison_profile,
2052            fixed_versions,
2053            prerequisites,
2054            remediation,
2055        });
2056    }
2057    validated_records.sort_by(|left, right| left.id.cmp(&right.id));
2058    Ok(WordPressAdvisoryCatalog {
2059        schema,
2060        metadata,
2061        records: validated_records,
2062    })
2063}
2064
2065pub fn evaluate_wordpress_review(
2066    signals: &[WordPressComponentSignal],
2067    inputs: &WordPressReviewInputs,
2068) -> Result<WordPressReviewResult, WordPressReviewError> {
2069    if signals.len() > MAX_WORDPRESS_SIGNALS {
2070        return Err(WordPressReviewError::SignalLimitExceeded);
2071    }
2072    let mut aggregates = BTreeMap::<WordPressComponentIdentity, ComponentAggregate>::new();
2073    for signal in signals {
2074        let aggregate = aggregates.entry(signal.identity.clone()).or_default();
2075        aggregate.sources.insert(signal.source);
2076        aggregate.confidence_classes.insert(signal.confidence());
2077        if let Some(version) = &signal.version {
2078            aggregate.versions.insert(WordPressVersionEvidence {
2079                value: version.clone(),
2080                source: signal.source,
2081            });
2082        }
2083    }
2084    if let Some(context) = inputs.context() {
2085        for component in context.components() {
2086            let aggregate = aggregates.entry(component.identity.clone()).or_default();
2087            aggregate
2088                .sources
2089                .insert(WordPressEvidenceSource::OperatorContext);
2090            aggregate
2091                .confidence_classes
2092                .insert(WordPressEvidenceConfidence::OperatorAssertion);
2093            aggregate.activation = component.activation;
2094            aggregate.inventory_status = component.inventory_status;
2095            if let Some(version) = &component.version {
2096                aggregate.versions.insert(WordPressVersionEvidence {
2097                    value: version.clone(),
2098                    source: WordPressEvidenceSource::OperatorContext,
2099                });
2100            }
2101        }
2102    }
2103    let result_version_count = aggregates.values().try_fold(0_usize, |count, aggregate| {
2104        count.checked_add(aggregate.versions.len())
2105    });
2106    if aggregates.len() > MAX_WORDPRESS_RESULT_COMPONENTS
2107        || result_version_count.is_none_or(|count| count > MAX_WORDPRESS_RESULT_VERSION_EVIDENCE)
2108    {
2109        return Err(WordPressReviewError::ResultLimitExceeded);
2110    }
2111    let mut components = Vec::with_capacity(aggregates.len());
2112    let profiled_catalog = inputs
2113        .catalog()
2114        .is_some_and(|catalog| catalog.schema() == WordPressAdvisoryCatalogSchema::V2)
2115        || inputs.wordfence_v3_catalog().is_some();
2116    for (identity, aggregate) in &aggregates {
2117        components.push(component_assessment(
2118            identity.clone(),
2119            aggregate,
2120            profiled_catalog,
2121        ));
2122    }
2123    if let Some(catalog) = inputs.wordfence_v3_catalog() {
2124        let external_review =
2125            evaluate_wordfence_v3_review(catalog, &components, inputs.external_version_profile())?;
2126        return Ok(WordPressReviewResult {
2127            catalog_status: WordPressCatalogStatus::Evaluated,
2128            catalog_schema: None,
2129            catalog_metadata: None,
2130            components,
2131            advisories: Vec::new(),
2132            external_review: Some(external_review),
2133            inventory_summary: inputs.inventory_summary.clone(),
2134            local_input_provenance: inputs.local_input_provenance.clone(),
2135        });
2136    }
2137    let Some(catalog) = inputs.catalog() else {
2138        return Ok(WordPressReviewResult {
2139            catalog_status: WordPressCatalogStatus::CatalogNotSupplied,
2140            catalog_schema: None,
2141            catalog_metadata: None,
2142            components,
2143            advisories: Vec::new(),
2144            external_review: None,
2145            inventory_summary: inputs.inventory_summary.clone(),
2146            local_input_provenance: inputs.local_input_provenance.clone(),
2147        });
2148    };
2149    let work = catalog.records().iter().try_fold(0_usize, |work, record| {
2150        work.checked_add(record.affected_range_count())?
2151            .checked_add(record.prerequisites.len())
2152    });
2153    if work.is_none_or(|work| work > MAX_WORDPRESS_EVALUATION_WORK) {
2154        return Err(WordPressReviewError::EvaluationLimitExceeded);
2155    }
2156    let assessments = components
2157        .iter()
2158        .map(|assessment| (assessment.identity.clone(), assessment))
2159        .collect::<BTreeMap<_, _>>();
2160    let mut profiled_resolutions = BTreeMap::new();
2161    if catalog.schema() == WordPressAdvisoryCatalogSchema::V2 {
2162        let profiles = catalog
2163            .records()
2164            .iter()
2165            .map(WordPressAdvisoryRecord::comparison_profile)
2166            .collect::<BTreeSet<_>>();
2167        let mut resolution_work = 0_usize;
2168        for (identity, assessment) in &assessments {
2169            for profile in &profiles {
2170                resolution_work = resolution_work
2171                    .checked_add(assessment.versions().len())
2172                    .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
2173                if resolution_work > MAX_WORDPRESS_VERSION_RESOLUTION_WORK {
2174                    return Err(WordPressReviewError::EvaluationLimitExceeded);
2175                }
2176                profiled_resolutions.insert(
2177                    ((*identity).clone(), *profile),
2178                    resolve_profiled_versions(assessment, *profile),
2179                );
2180            }
2181        }
2182    }
2183    let mut advisories = Vec::with_capacity(catalog.records().len());
2184    for record in catalog.records() {
2185        let assessment = assessments.get(record.component());
2186        let (component_evidence, version_relation, version_resolution, resolution_reason) =
2187            match catalog.schema() {
2188                WordPressAdvisoryCatalogSchema::V1 => {
2189                    let component_evidence = assessment
2190                        .map_or(WordPressComponentEvidenceClass::Unknown, |assessment| {
2191                            assessment.evidence_class
2192                        });
2193                    (
2194                        component_evidence,
2195                        evaluate_version_relation(assessment.copied(), record),
2196                        None,
2197                        None,
2198                    )
2199                },
2200                WordPressAdvisoryCatalogSchema::V2 => {
2201                    let resolution = assessment
2202                        .and_then(|assessment| {
2203                            profiled_resolutions
2204                                .get(&(assessment.identity().clone(), record.comparison_profile()))
2205                        })
2206                        .cloned()
2207                        .unwrap_or_else(VersionResolutionState::missing);
2208                    (
2209                        assessment.map_or(WordPressComponentEvidenceClass::Unknown, |assessment| {
2210                            profile_independent_component_evidence(assessment)
2211                        }),
2212                        evaluate_profiled_version_relation(&resolution, record),
2213                        Some(resolution.status),
2214                        Some(resolution.reason),
2215                    )
2216                },
2217            };
2218        let prerequisites = record
2219            .prerequisites
2220            .iter()
2221            .cloned()
2222            .map(|prerequisite| {
2223                let outcome =
2224                    evaluate_prerequisite(&prerequisite, inputs.context(), record.component());
2225                WordPressPrerequisiteEvaluation {
2226                    prerequisite,
2227                    outcome,
2228                }
2229            })
2230            .collect::<Vec<_>>();
2231        let applicability =
2232            summarize_applicability(component_evidence, version_relation, &prerequisites);
2233        advisories.push(WordPressAdvisoryEvaluation {
2234            record: record.clone(),
2235            component_evidence,
2236            version_relation,
2237            version_resolution,
2238            version_resolution_reason: resolution_reason,
2239            prerequisites,
2240            applicability,
2241        });
2242    }
2243    Ok(WordPressReviewResult {
2244        catalog_status: WordPressCatalogStatus::Evaluated,
2245        catalog_schema: Some(catalog.schema()),
2246        catalog_metadata: Some(catalog.metadata().clone()),
2247        components,
2248        advisories,
2249        external_review: None,
2250        inventory_summary: inputs.inventory_summary.clone(),
2251        local_input_provenance: inputs.local_input_provenance.clone(),
2252    })
2253}
2254
2255fn evaluate_wordfence_v3_review(
2256    catalog: &WordfenceV3Catalog,
2257    components: &[WordPressComponentAssessment],
2258    selected_profile: Option<WordPressComparisonProfile>,
2259) -> Result<WordPressExternalReview, WordPressReviewError> {
2260    let assessments = components
2261        .iter()
2262        .map(|assessment| (assessment.identity().clone(), assessment))
2263        .collect::<BTreeMap<_, _>>();
2264    let mut selected = Vec::new();
2265    let mut keys = BTreeSet::new();
2266    let mut selected_notice_ids = BTreeSet::new();
2267    let mut evaluation_work = 0_usize;
2268    let mut resolution_work = 0_usize;
2269    let mut projected_result_bytes = 0_usize;
2270    let mut identity_limitations = Vec::new();
2271    for (component, assessment) in &assessments {
2272        let mut profiled_resolution = None;
2273        for view in catalog.associations_for(component) {
2274            if selected.len() == MAX_WORDPRESS_ADVISORY_RECORDS {
2275                return Err(WordPressReviewError::ResultLimitExceeded);
2276            }
2277            let record = view.record();
2278            let association = view.association();
2279            projected_result_bytes = projected_result_bytes
2280                .checked_add(wordfence_v3_projection_bytes(record, association)?)
2281                .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2282            if projected_result_bytes > MAX_WORDPRESS_EXTERNAL_RESULT_BYTES {
2283                return Err(WordPressReviewError::ResultLimitExceeded);
2284            }
2285            let exact_identity =
2286                association.identity_mapping() == WordfenceV3IdentityMapping::Exact;
2287            if selected_profile.is_some() && exact_identity {
2288                evaluation_work = checked_accumulate_external_interpretation_work(
2289                    evaluation_work,
2290                    association.affected_ranges().len(),
2291                    association.patched_versions().len(),
2292                    MAX_WORDPRESS_EVALUATION_WORK,
2293                )
2294                .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
2295            } else {
2296                evaluation_work = evaluation_work
2297                    .checked_add(association.affected_ranges().len())
2298                    .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
2299                if evaluation_work > MAX_WORDPRESS_EVALUATION_WORK {
2300                    return Err(WordPressReviewError::EvaluationLimitExceeded);
2301                }
2302            }
2303            let key = WordPressExternalAssociationKey {
2304                source_namespace: WORDFENCE_V3_SOURCE_NAMESPACE.to_owned(),
2305                upstream_id: record.upstream_id().to_owned(),
2306                component: association.component().clone(),
2307                source_component: association.source_component().clone(),
2308                source_association_sha256: *association.key().source_association_sha256(),
2309            };
2310            if !keys.insert(key.clone()) {
2311                return Err(WordPressReviewError::InvalidExternalCatalog);
2312            }
2313            selected_notice_ids.extend(record.notice_ids().iter().cloned());
2314            if selected_notice_ids.len() > MAX_WORDPRESS_ADVISORY_RECORDS {
2315                return Err(WordPressReviewError::ResultLimitExceeded);
2316            }
2317            let profile_evaluation =
2318                if let Some(profile) = selected_profile.filter(|_| exact_identity) {
2319                    if profiled_resolution.is_none() {
2320                        resolution_work = resolution_work
2321                            .checked_add(assessment.versions().len())
2322                            .ok_or(WordPressReviewError::EvaluationLimitExceeded)?;
2323                        if resolution_work > MAX_WORDPRESS_VERSION_RESOLUTION_WORK {
2324                            return Err(WordPressReviewError::EvaluationLimitExceeded);
2325                        }
2326                        profiled_resolution = Some(resolve_profiled_versions(assessment, profile));
2327                    }
2328                    Some(evaluate_wordfence_profiled_association(
2329                        profiled_resolution
2330                            .as_ref()
2331                            .ok_or(WordPressReviewError::InvalidExternalCatalog)?,
2332                        association,
2333                        profile,
2334                    )?)
2335                } else {
2336                    None
2337                };
2338            let version_evidence_resolution = profile_evaluation.as_ref().map_or_else(
2339                || external_version_evidence_resolution(assessment, None),
2340                |evaluation| {
2341                    external_version_evidence_resolution(
2342                        assessment,
2343                        Some(&evaluation.version_resolution),
2344                    )
2345                },
2346            )?;
2347            let identity_limited_reason = match association.identity_mapping() {
2348                WordfenceV3IdentityMapping::Exact => None,
2349                WordfenceV3IdentityMapping::AsciiCaseFoldCandidate => {
2350                    Some(WordPressExternalVersionRelationReason::IdentityMappingCandidate)
2351                },
2352                WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous => {
2353                    Some(WordPressExternalVersionRelationReason::IdentityMappingAmbiguous)
2354                },
2355            };
2356            let identity_limited_range_reason = match association.identity_mapping() {
2357                WordfenceV3IdentityMapping::Exact => None,
2358                WordfenceV3IdentityMapping::AsciiCaseFoldCandidate => {
2359                    Some(WordPressExternalRangeReason::IdentityMappingCandidate)
2360                },
2361                WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous => {
2362                    Some(WordPressExternalRangeReason::IdentityMappingAmbiguous)
2363                },
2364            };
2365            let range_evaluations = if selected_profile.is_none() {
2366                None
2367            } else if let Some(evaluation) = &profile_evaluation {
2368                Some(evaluation.ranges.clone())
2369            } else {
2370                let reason = identity_limited_range_reason
2371                    .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2372                Some(
2373                    association
2374                        .affected_ranges()
2375                        .iter()
2376                        .map(|_| WordPressExternalRangeEvaluation {
2377                            relation: WordPressExternalRangeRelation::NotEvaluated,
2378                            reason,
2379                        })
2380                        .collect(),
2381                )
2382            };
2383            selected.push(WordPressExternalAdvisoryEvaluation {
2384                key,
2385                identity_mapping: association.identity_mapping(),
2386                identity_collision_raw_count: association.identity_collision_raw_count(),
2387                title: record.title().to_owned(),
2388                informational: record.informational(),
2389                description: record.description().to_owned(),
2390                references: record.references().to_vec(),
2391                cwe: record.cwe().cloned(),
2392                cvss: record.cvss().cloned(),
2393                cve: record.cve().map(str::to_owned),
2394                cve_link: record.cve_link().map(str::to_owned),
2395                researchers: record.researchers().to_vec(),
2396                published: record.published().map(str::to_owned),
2397                updated: record.updated().map(str::to_owned),
2398                notice_ids: record.notice_ids().to_vec(),
2399                display_name: association.display_name().to_owned(),
2400                affected_ranges: association.affected_ranges().to_vec(),
2401                range_evaluations,
2402                source_patched: association.patched(),
2403                source_patched_versions: association.patched_versions().to_vec(),
2404                source_remediation: association.remediation().to_owned(),
2405                component_evidence: profile_independent_component_evidence(assessment),
2406                version_evidence_resolution,
2407                comparison_policy: if selected_profile.is_some() {
2408                    WordPressExternalComparisonPolicy::TermivarWordfenceV3ExplicitInterpretationV1
2409                } else {
2410                    WordPressExternalComparisonPolicy::WordfenceV3SourceSemanticsUnresolvedV1
2411                },
2412                version_relation: if selected_profile.is_none() {
2413                    WordPressExternalVersionRelation::SourceComparisonSemanticsUnresolved
2414                } else {
2415                    profile_evaluation.as_ref().map_or(
2416                        WordPressExternalVersionRelation::Indeterminate,
2417                        |evaluation| evaluation.relation,
2418                    )
2419                },
2420                version_relation_reason: if selected_profile.is_none() {
2421                    WordPressExternalVersionRelationReason::SourceComparisonSemanticsUnresolved
2422                } else {
2423                    profile_evaluation.as_ref().map_or_else(
2424                        || {
2425                            identity_limited_reason
2426                                .ok_or(WordPressReviewError::InvalidExternalCatalog)
2427                        },
2428                        |evaluation| Ok(evaluation.reason),
2429                    )?
2430                },
2431                applicability: if selected_profile.is_none() {
2432                    WordPressExternalApplicability::IndeterminateUnsupported
2433                } else {
2434                    profile_evaluation.as_ref().map_or(
2435                        WordPressExternalApplicability::Indeterminate,
2436                        |evaluation| evaluation.applicability,
2437                    )
2438                },
2439            });
2440        }
2441    }
2442    selected.sort_by(|left, right| left.key.cmp(&right.key));
2443
2444    let notices_by_id = catalog
2445        .notices()
2446        .iter()
2447        .map(|notice| (notice.id(), notice))
2448        .collect::<BTreeMap<_, _>>();
2449    for id in &selected_notice_ids {
2450        let notice = notices_by_id
2451            .get(id.as_str())
2452            .copied()
2453            .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2454        projected_result_bytes = projected_result_bytes
2455            .checked_add(wordfence_v3_notice_projection_bytes(notice)?)
2456            .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2457        if projected_result_bytes > MAX_WORDPRESS_EXTERNAL_RESULT_BYTES {
2458            return Err(WordPressReviewError::ResultLimitExceeded);
2459        }
2460    }
2461
2462    let mut identity_projection_bytes = 0_usize;
2463    'identity_projection: for record in catalog.records() {
2464        for association in record.unresolved_software() {
2465            if identity_limitations.len() == MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS
2466            {
2467                break 'identity_projection;
2468            }
2469            let row_bytes = wordfence_v3_unresolved_projection_bytes(record, association)?;
2470            let mut new_notice_bytes = 0_usize;
2471            let mut new_notice_count = 0_usize;
2472            for id in record.notice_ids() {
2473                if !selected_notice_ids.contains(id) {
2474                    let notice = notices_by_id
2475                        .get(id.as_str())
2476                        .copied()
2477                        .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2478                    new_notice_bytes = new_notice_bytes
2479                        .checked_add(wordfence_v3_notice_projection_bytes(notice)?)
2480                        .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2481                    new_notice_count = new_notice_count
2482                        .checked_add(1)
2483                        .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2484                }
2485            }
2486            let next_identity_bytes = identity_projection_bytes
2487                .checked_add(row_bytes)
2488                .and_then(|bytes| bytes.checked_add(new_notice_bytes));
2489            let next_result_bytes = projected_result_bytes
2490                .checked_add(row_bytes)
2491                .and_then(|bytes| bytes.checked_add(new_notice_bytes));
2492            let next_notice_count = selected_notice_ids.len().checked_add(new_notice_count);
2493            if next_identity_bytes.is_none_or(|bytes| {
2494                bytes > MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTION_BYTES
2495            }) || next_result_bytes
2496                .is_none_or(|bytes| bytes > MAX_WORDPRESS_EXTERNAL_RESULT_BYTES)
2497                || next_notice_count.is_none_or(|count| count > MAX_WORDPRESS_ADVISORY_RECORDS)
2498            {
2499                break 'identity_projection;
2500            }
2501            identity_projection_bytes =
2502                next_identity_bytes.ok_or(WordPressReviewError::ResultLimitExceeded)?;
2503            projected_result_bytes =
2504                next_result_bytes.ok_or(WordPressReviewError::ResultLimitExceeded)?;
2505            selected_notice_ids.extend(record.notice_ids().iter().cloned());
2506            identity_limitations.push(WordPressExternalIdentityLimitation {
2507                upstream_id: record.upstream_id().to_owned(),
2508                source_component: association.source_component().clone(),
2509                source_association_sha256: *association.source_association_sha256(),
2510                title: record.title().to_owned(),
2511                display_name: association.display_name().to_owned(),
2512                references: record.references().to_vec(),
2513                affected_ranges: association.affected_ranges().to_vec(),
2514                source_patched: association.patched(),
2515                source_patched_versions: association.patched_versions().to_vec(),
2516                source_remediation: association.remediation().to_owned(),
2517                notice_ids: record.notice_ids().to_vec(),
2518            });
2519        }
2520    }
2521    let mut notices = Vec::with_capacity(selected_notice_ids.len());
2522    for id in &selected_notice_ids {
2523        let notice = notices_by_id
2524            .get(id.as_str())
2525            .copied()
2526            .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2527        notices.push(notice.clone());
2528    }
2529    let selected_associations = selected.len();
2530    let software_associations = catalog.software_association_count();
2531    let projected_identity_limitations = identity_limitations.len();
2532    let unprojected_identity_limitations = catalog
2533        .identity_counts()
2534        .unresolved()
2535        .checked_sub(projected_identity_limitations)
2536        .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2537    let excluded_associations = software_associations
2538        .checked_sub(selected_associations)
2539        .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2540    let mapped_unselected_associations = excluded_associations
2541        .checked_sub(catalog.identity_counts().unresolved())
2542        .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2543    let within_associations = selected
2544        .iter()
2545        .filter(|evaluation| {
2546            evaluation.version_relation
2547                == WordPressExternalVersionRelation::WithinSupportedRangeUnderSelectedPolicy
2548        })
2549        .count();
2550    let outside_associations = selected
2551        .iter()
2552        .filter(|evaluation| {
2553            evaluation.version_relation
2554                == WordPressExternalVersionRelation::OutsideDeclaredRangesUnderSelectedPolicy
2555        })
2556        .count();
2557    let indeterminate_associations = if selected_profile.is_some() {
2558        selected_associations
2559            .checked_sub(within_associations)
2560            .and_then(|count| count.checked_sub(outside_associations))
2561            .ok_or(WordPressReviewError::InvalidExternalCatalog)?
2562    } else {
2563        0
2564    };
2565    let evaluable_associations = within_associations
2566        .checked_add(outside_associations)
2567        .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2568    let unsupported_associations = if selected_profile.is_some() {
2569        indeterminate_associations
2570    } else {
2571        selected_associations
2572    };
2573    let mut selected_ranges = 0_usize;
2574    let mut containing_ranges = 0_usize;
2575    let mut noncontaining_ranges = 0_usize;
2576    let mut unsupported_ranges = 0_usize;
2577    let mut invalid_ranges = 0_usize;
2578    let mut not_evaluated_ranges = 0_usize;
2579    let mut partial_range_coverage_associations = 0_usize;
2580    if selected_profile.is_some() {
2581        for evaluation in &selected {
2582            let ranges = evaluation
2583                .range_evaluations
2584                .as_deref()
2585                .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2586            selected_ranges = selected_ranges
2587                .checked_add(ranges.len())
2588                .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2589            for range in ranges {
2590                match range.relation {
2591                    WordPressExternalRangeRelation::Contains => containing_ranges += 1,
2592                    WordPressExternalRangeRelation::DoesNotContain => noncontaining_ranges += 1,
2593                    WordPressExternalRangeRelation::NotEvaluated => not_evaluated_ranges += 1,
2594                    WordPressExternalRangeRelation::Unsupported => unsupported_ranges += 1,
2595                    WordPressExternalRangeRelation::InvalidUnderProfile => invalid_ranges += 1,
2596                }
2597            }
2598            if evaluation.version_relation_reason
2599                == WordPressExternalVersionRelationReason::ContainingRangeWithPartialCoverage
2600            {
2601                partial_range_coverage_associations += 1;
2602            }
2603        }
2604    }
2605    let evaluated_ranges = containing_ranges
2606        .checked_add(noncontaining_ranges)
2607        .ok_or(WordPressReviewError::InvalidExternalCatalog)?;
2608    Ok(WordPressExternalReview {
2609        byte_length: catalog.byte_length(),
2610        sha256: *catalog.sha256(),
2611        semantic_sha256: *catalog.semantic_sha256(),
2612        retained_bytes: catalog.retained_bytes(),
2613        mapping_revision: catalog.mapping_revision(),
2614        resource_policy: catalog.resource_policy(),
2615        identity_counts: catalog.identity_counts().clone(),
2616        comparison_policy: if selected_profile.is_some() {
2617            WordPressExternalComparisonPolicy::TermivarWordfenceV3ExplicitInterpretationV1
2618        } else {
2619            WordPressExternalComparisonPolicy::WordfenceV3SourceSemanticsUnresolvedV1
2620        },
2621        comparison_profile: selected_profile,
2622        counts: WordPressExternalReviewCounts {
2623            parsed_records: catalog.record_count(),
2624            software_associations,
2625            exact_identity_associations: catalog.identity_counts().exact(),
2626            candidate_identity_associations: catalog.identity_counts().candidate(),
2627            ambiguous_identity_associations: catalog.identity_counts().ambiguous(),
2628            unresolved_identity_associations: catalog.identity_counts().unresolved(),
2629            projected_identity_limitations,
2630            unprojected_identity_limitations,
2631            selected_associations,
2632            evaluable_associations,
2633            unsupported_associations,
2634            excluded_associations,
2635            mapped_unselected_associations,
2636            within_associations,
2637            outside_associations,
2638            indeterminate_associations,
2639            selected_ranges,
2640            evaluated_ranges,
2641            containing_ranges,
2642            noncontaining_ranges,
2643            unsupported_ranges,
2644            invalid_ranges,
2645            not_evaluated_ranges,
2646            partial_range_coverage_associations,
2647        },
2648        notices,
2649        evaluations: selected,
2650        identity_limitations,
2651    })
2652}
2653
2654struct WordPressExternalProfiledEvaluation {
2655    version_resolution: VersionResolutionState,
2656    ranges: Vec<WordPressExternalRangeEvaluation>,
2657    relation: WordPressExternalVersionRelation,
2658    reason: WordPressExternalVersionRelationReason,
2659    applicability: WordPressExternalApplicability,
2660}
2661
2662enum ParsedExternalRange {
2663    Supported {
2664        lower: Option<(ProfiledVersionKey, bool)>,
2665        upper: Option<(ProfiledVersionKey, bool)>,
2666    },
2667    Unsupported(WordPressExternalRangeReason),
2668    Invalid(WordPressExternalRangeReason),
2669}
2670
2671fn evaluate_wordfence_profiled_association(
2672    version_resolution: &VersionResolutionState,
2673    association: &WordfenceV3SoftwareAssociation,
2674    profile: WordPressComparisonProfile,
2675) -> Result<WordPressExternalProfiledEvaluation, WordPressReviewError> {
2676    let parsed_ranges = association
2677        .affected_ranges()
2678        .iter()
2679        .map(|range| parse_external_range(range, profile))
2680        .collect::<Result<Vec<_>, _>>()?;
2681    let ranges = parsed_ranges
2682        .iter()
2683        .map(|range| evaluate_external_range(range, version_resolution))
2684        .collect::<Result<Vec<_>, _>>()?;
2685    let source_patched_version_conflict = external_patched_version_conflicts(
2686        association.patched_versions(),
2687        &parsed_ranges,
2688        profile,
2689    )?;
2690
2691    let has_invalid = ranges
2692        .iter()
2693        .any(|range| range.relation == WordPressExternalRangeRelation::InvalidUnderProfile);
2694    let has_unsupported = ranges
2695        .iter()
2696        .any(|range| range.relation == WordPressExternalRangeRelation::Unsupported);
2697    let has_containing = ranges
2698        .iter()
2699        .any(|range| range.relation == WordPressExternalRangeRelation::Contains);
2700    let all_outside = !ranges.is_empty()
2701        && ranges
2702            .iter()
2703            .all(|range| range.relation == WordPressExternalRangeRelation::DoesNotContain);
2704
2705    let (relation, reason, applicability) = if has_invalid {
2706        (
2707            WordPressExternalVersionRelation::Indeterminate,
2708            WordPressExternalVersionRelationReason::InvalidAffectedRange,
2709            WordPressExternalApplicability::Indeterminate,
2710        )
2711    } else if source_patched_version_conflict {
2712        (
2713            WordPressExternalVersionRelation::Indeterminate,
2714            WordPressExternalVersionRelationReason::SourcePatchedVersionWithinAffectedRange,
2715            WordPressExternalApplicability::Indeterminate,
2716        )
2717    } else {
2718        match version_resolution.status {
2719            WordPressVersionResolution::Missing => (
2720                WordPressExternalVersionRelation::Indeterminate,
2721                WordPressExternalVersionRelationReason::MissingVersionEvidence,
2722                WordPressExternalApplicability::Indeterminate,
2723            ),
2724            WordPressVersionResolution::Conflicting => (
2725                WordPressExternalVersionRelation::Indeterminate,
2726                WordPressExternalVersionRelationReason::ConflictingVersionEvidence,
2727                WordPressExternalApplicability::Indeterminate,
2728            ),
2729            WordPressVersionResolution::Unsupported => (
2730                WordPressExternalVersionRelation::Indeterminate,
2731                WordPressExternalVersionRelationReason::UnsupportedVersionEvidence,
2732                WordPressExternalApplicability::Indeterminate,
2733            ),
2734            WordPressVersionResolution::SupportedEquivalent if ranges.is_empty() => (
2735                WordPressExternalVersionRelation::Indeterminate,
2736                WordPressExternalVersionRelationReason::MissingAffectedRanges,
2737                WordPressExternalApplicability::Indeterminate,
2738            ),
2739            WordPressVersionResolution::SupportedEquivalent if has_containing => (
2740                WordPressExternalVersionRelation::WithinSupportedRangeUnderSelectedPolicy,
2741                if has_unsupported {
2742                    WordPressExternalVersionRelationReason::ContainingRangeWithPartialCoverage
2743                } else {
2744                    WordPressExternalVersionRelationReason::ContainingRange
2745                },
2746                WordPressExternalApplicability::VersionMatchUnderSelectedPolicy,
2747            ),
2748            WordPressVersionResolution::SupportedEquivalent if has_unsupported => (
2749                WordPressExternalVersionRelation::Indeterminate,
2750                WordPressExternalVersionRelationReason::UnsupportedAffectedRange,
2751                WordPressExternalApplicability::Indeterminate,
2752            ),
2753            WordPressVersionResolution::SupportedEquivalent if all_outside => (
2754                WordPressExternalVersionRelation::OutsideDeclaredRangesUnderSelectedPolicy,
2755                WordPressExternalVersionRelationReason::AllRangesOutside,
2756                WordPressExternalApplicability::NoVersionMatchUnderSelectedPolicy,
2757            ),
2758            WordPressVersionResolution::SupportedEquivalent => {
2759                return Err(WordPressReviewError::InvalidExternalCatalog);
2760            },
2761        }
2762    };
2763
2764    Ok(WordPressExternalProfiledEvaluation {
2765        version_resolution: version_resolution.clone(),
2766        ranges,
2767        relation,
2768        reason,
2769        applicability,
2770    })
2771}
2772
2773fn external_patched_version_conflicts(
2774    patched_versions: &[String],
2775    ranges: &[ParsedExternalRange],
2776    profile: WordPressComparisonProfile,
2777) -> Result<bool, WordPressReviewError> {
2778    for declared in patched_versions {
2779        let Ok(version) = ProfiledVersionKey::parse(profile, declared) else {
2780            continue;
2781        };
2782        for range in ranges {
2783            let ParsedExternalRange::Supported { lower, upper } = range else {
2784                continue;
2785            };
2786            let above_lower = lower.as_ref().is_none_or(|(bound, inclusive)| {
2787                bound.compare(&version).ok().is_some_and(|ordering| {
2788                    ordering == Ordering::Less || (ordering == Ordering::Equal && *inclusive)
2789                })
2790            });
2791            let below_upper = upper.as_ref().is_none_or(|(bound, inclusive)| {
2792                version.compare(bound).ok().is_some_and(|ordering| {
2793                    ordering == Ordering::Less || (ordering == Ordering::Equal && *inclusive)
2794                })
2795            });
2796            if above_lower && below_upper {
2797                return Ok(true);
2798            }
2799        }
2800    }
2801    Ok(false)
2802}
2803
2804fn parse_external_range(
2805    range: &WordfenceV3AffectedRange,
2806    profile: WordPressComparisonProfile,
2807) -> Result<ParsedExternalRange, WordPressReviewError> {
2808    let lower = match range.from().value() {
2809        WordfenceV3RangeValue::Any => Ok(None),
2810        WordfenceV3RangeValue::Declared(value) => ProfiledVersionKey::parse(profile, value)
2811            .map(|version| Some((version, range.from().inclusive()))),
2812    };
2813    let upper = match range.to().value() {
2814        WordfenceV3RangeValue::Any => Ok(None),
2815        WordfenceV3RangeValue::Declared(value) => ProfiledVersionKey::parse(profile, value)
2816            .map(|version| Some((version, range.to().inclusive()))),
2817    };
2818    let (lower, upper) = match (lower, upper) {
2819        (Err(_), Err(_)) => {
2820            return Ok(ParsedExternalRange::Unsupported(
2821                WordPressExternalRangeReason::UnsupportedBothBounds,
2822            ));
2823        },
2824        (Err(_), Ok(_)) => {
2825            return Ok(ParsedExternalRange::Unsupported(
2826                WordPressExternalRangeReason::UnsupportedLowerBound,
2827            ));
2828        },
2829        (Ok(_), Err(_)) => {
2830            return Ok(ParsedExternalRange::Unsupported(
2831                WordPressExternalRangeReason::UnsupportedUpperBound,
2832            ));
2833        },
2834        (Ok(lower), Ok(upper)) => (lower, upper),
2835    };
2836    if let (Some((lower_version, lower_inclusive)), Some((upper_version, upper_inclusive))) =
2837        (&lower, &upper)
2838    {
2839        match lower_version
2840            .compare(upper_version)
2841            .map_err(|_| WordPressReviewError::InvalidExternalCatalog)?
2842        {
2843            Ordering::Greater => {
2844                return Ok(ParsedExternalRange::Invalid(
2845                    WordPressExternalRangeReason::ReversedBounds,
2846                ));
2847            },
2848            Ordering::Equal if !(*lower_inclusive && *upper_inclusive) => {
2849                return Ok(ParsedExternalRange::Invalid(
2850                    WordPressExternalRangeReason::EmptyExclusiveInterval,
2851                ));
2852            },
2853            Ordering::Less | Ordering::Equal => {},
2854        }
2855    }
2856    Ok(ParsedExternalRange::Supported { lower, upper })
2857}
2858
2859fn evaluate_external_range(
2860    range: &ParsedExternalRange,
2861    resolution: &VersionResolutionState,
2862) -> Result<WordPressExternalRangeEvaluation, WordPressReviewError> {
2863    match range {
2864        ParsedExternalRange::Unsupported(reason) => Ok(WordPressExternalRangeEvaluation {
2865            relation: WordPressExternalRangeRelation::Unsupported,
2866            reason: *reason,
2867        }),
2868        ParsedExternalRange::Invalid(reason) => Ok(WordPressExternalRangeEvaluation {
2869            relation: WordPressExternalRangeRelation::InvalidUnderProfile,
2870            reason: *reason,
2871        }),
2872        ParsedExternalRange::Supported { lower, upper } => {
2873            let Some(version) = resolution.version.as_ref() else {
2874                let reason = match resolution.status {
2875                    WordPressVersionResolution::Missing => {
2876                        WordPressExternalRangeReason::MissingVersionEvidence
2877                    },
2878                    WordPressVersionResolution::Conflicting => {
2879                        WordPressExternalRangeReason::ConflictingVersionEvidence
2880                    },
2881                    WordPressVersionResolution::Unsupported => {
2882                        WordPressExternalRangeReason::UnsupportedVersionEvidence
2883                    },
2884                    WordPressVersionResolution::SupportedEquivalent => {
2885                        return Err(WordPressReviewError::InvalidExternalCatalog);
2886                    },
2887                };
2888                return Ok(WordPressExternalRangeEvaluation {
2889                    relation: WordPressExternalRangeRelation::NotEvaluated,
2890                    reason,
2891                });
2892            };
2893            let above_lower = match lower {
2894                None => true,
2895                Some((bound, inclusive)) => match version
2896                    .compare(bound)
2897                    .map_err(|_| WordPressReviewError::InvalidExternalCatalog)?
2898                {
2899                    Ordering::Greater => true,
2900                    Ordering::Equal => *inclusive,
2901                    Ordering::Less => false,
2902                },
2903            };
2904            let below_upper = match upper {
2905                None => true,
2906                Some((bound, inclusive)) => match version
2907                    .compare(bound)
2908                    .map_err(|_| WordPressReviewError::InvalidExternalCatalog)?
2909                {
2910                    Ordering::Less => true,
2911                    Ordering::Equal => *inclusive,
2912                    Ordering::Greater => false,
2913                },
2914            };
2915            let contains = above_lower && below_upper;
2916            Ok(WordPressExternalRangeEvaluation {
2917                relation: if contains {
2918                    WordPressExternalRangeRelation::Contains
2919                } else {
2920                    WordPressExternalRangeRelation::DoesNotContain
2921                },
2922                reason: if contains {
2923                    WordPressExternalRangeReason::SelectedVersionWithinBounds
2924                } else {
2925                    WordPressExternalRangeReason::SelectedVersionOutsideBounds
2926                },
2927            })
2928        },
2929    }
2930}
2931
2932fn external_version_evidence_resolution(
2933    assessment: &WordPressComponentAssessment,
2934    semantic: Option<&VersionResolutionState>,
2935) -> Result<WordPressExternalVersionEvidenceResolution, WordPressReviewError> {
2936    let evidence_row_count = assessment.versions.len();
2937    let distinct_spelling_count = assessment
2938        .versions
2939        .iter()
2940        .map(|version| version.value.as_str())
2941        .collect::<BTreeSet<_>>()
2942        .len();
2943    let status = match (evidence_row_count, distinct_spelling_count) {
2944        (0, 0) => WordPressExternalVersionEvidenceStatus::Missing,
2945        (1, 1) => WordPressExternalVersionEvidenceStatus::SingleDeclaration,
2946        (count, 1) if count > 1 => WordPressExternalVersionEvidenceStatus::RepeatedExactDeclaration,
2947        (count, distinct) if count > 1 && distinct > 1 && distinct <= count => {
2948            WordPressExternalVersionEvidenceStatus::MultipleDistinctDeclarations
2949        },
2950        _ => return Err(WordPressReviewError::InvalidExternalCatalog),
2951    };
2952    Ok(WordPressExternalVersionEvidenceResolution {
2953        status,
2954        evidence_row_count,
2955        distinct_spelling_count,
2956        semantic_status: semantic.map(|resolution| resolution.status),
2957        semantic_reason: semantic.map(|resolution| resolution.reason),
2958    })
2959}
2960
2961fn wordfence_v3_projection_bytes(
2962    record: &WordfenceV3Record,
2963    association: &WordfenceV3SoftwareAssociation,
2964) -> Result<usize, WordPressReviewError> {
2965    let mut total = 512_usize;
2966    for value in [
2967        record.upstream_id(),
2968        record.title(),
2969        record.description(),
2970        record.cve().unwrap_or_default(),
2971        record.cve_link().unwrap_or_default(),
2972        record.published().unwrap_or_default(),
2973        record.updated().unwrap_or_default(),
2974        association.source_component().slug(),
2975        association.display_name(),
2976        association.remediation(),
2977    ] {
2978        total = total
2979            .checked_add(value.len())
2980            .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2981    }
2982    for value in record
2983        .references()
2984        .iter()
2985        .chain(record.researchers())
2986        .chain(record.notice_ids())
2987        .chain(association.patched_versions())
2988    {
2989        total = total
2990            .checked_add(value.len() + 16)
2991            .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2992    }
2993    if let Some(cwe) = record.cwe() {
2994        total = total
2995            .checked_add(cwe.name().len() + cwe.description().len() + 32)
2996            .ok_or(WordPressReviewError::ResultLimitExceeded)?;
2997    }
2998    if let Some(cvss) = record.cvss() {
2999        total = total
3000            .checked_add(cvss.vector().len() + cvss.score().len() + 32)
3001            .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3002    }
3003    for range in association.affected_ranges() {
3004        total = total
3005            .checked_add(
3006                range.label().len()
3007                    + range.from().value().declared().len()
3008                    + range.to().value().declared().len()
3009                    + 64,
3010            )
3011            .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3012    }
3013    Ok(total)
3014}
3015
3016fn wordfence_v3_unresolved_projection_bytes(
3017    record: &WordfenceV3Record,
3018    association: &WordfenceV3UnresolvedSoftwareAssociation,
3019) -> Result<usize, WordPressReviewError> {
3020    let mut total = 256_usize;
3021    for value in [
3022        record.upstream_id(),
3023        record.title(),
3024        association.source_component().slug(),
3025        association.display_name(),
3026        association.remediation(),
3027    ] {
3028        total = total
3029            .checked_add(value.len())
3030            .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3031    }
3032    for value in record
3033        .references()
3034        .iter()
3035        .chain(record.notice_ids())
3036        .chain(association.patched_versions())
3037    {
3038        total = total
3039            .checked_add(value.len() + 16)
3040            .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3041    }
3042    for range in association.affected_ranges() {
3043        total = total
3044            .checked_add(
3045                range.label().len()
3046                    + range.from().value().declared().len()
3047                    + range.to().value().declared().len()
3048                    + 64,
3049            )
3050            .ok_or(WordPressReviewError::ResultLimitExceeded)?;
3051    }
3052    Ok(total)
3053}
3054
3055fn wordfence_v3_notice_projection_bytes(
3056    notice: &WordfenceV3Notice,
3057) -> Result<usize, WordPressReviewError> {
3058    [
3059        notice.id(),
3060        notice.message(),
3061        notice.party(),
3062        notice.notice(),
3063        notice.license(),
3064        notice.license_url(),
3065    ]
3066    .iter()
3067    .try_fold(128_usize, |total, value| {
3068        total
3069            .checked_add(value.len())
3070            .ok_or(WordPressReviewError::ResultLimitExceeded)
3071    })
3072}
3073
3074#[derive(Default)]
3075struct ComponentAggregate {
3076    sources: BTreeSet<WordPressEvidenceSource>,
3077    confidence_classes: BTreeSet<WordPressEvidenceConfidence>,
3078    versions: BTreeSet<WordPressVersionEvidence>,
3079    activation: Option<WordPressActivationState>,
3080    inventory_status: Option<WordPressInventoryEntryStatus>,
3081}
3082
3083fn component_assessment(
3084    identity: WordPressComponentIdentity,
3085    aggregate: &ComponentAggregate,
3086    profiled_catalog: bool,
3087) -> WordPressComponentAssessment {
3088    let conflicting = !profiled_catalog && versions_conflict(&aggregate.versions);
3089    let observed = aggregate.sources.iter().any(|source| {
3090        matches!(
3091            source,
3092            WordPressEvidenceSource::GeneratorMetadata
3093                | WordPressEvidenceSource::SameOriginAssetPath
3094                | WordPressEvidenceSource::ThemeStylesheetDeclaration
3095        )
3096    });
3097    let operator = aggregate
3098        .sources
3099        .contains(&WordPressEvidenceSource::OperatorContext);
3100    let evidence_class = if conflicting {
3101        WordPressComponentEvidenceClass::Conflicting
3102    } else if observed {
3103        WordPressComponentEvidenceClass::ObservedHint
3104    } else if operator {
3105        WordPressComponentEvidenceClass::OperatorSupplied
3106    } else {
3107        WordPressComponentEvidenceClass::Unknown
3108    };
3109    WordPressComponentAssessment {
3110        identity,
3111        evidence_class,
3112        identity_sources: aggregate.sources.iter().copied().collect(),
3113        confidence_classes: aggregate.confidence_classes.iter().copied().collect(),
3114        versions: aggregate.versions.iter().cloned().collect(),
3115        activation: aggregate.activation,
3116        inventory_status: aggregate.inventory_status,
3117    }
3118}
3119
3120const fn confidence_for_source(source: WordPressEvidenceSource) -> WordPressEvidenceConfidence {
3121    match source {
3122        WordPressEvidenceSource::GeneratorMetadata => {
3123            WordPressEvidenceConfidence::PublicDeclaration
3124        },
3125        WordPressEvidenceSource::SameOriginAssetPath => WordPressEvidenceConfidence::StructuralHint,
3126        WordPressEvidenceSource::ThemeStylesheetDeclaration => {
3127            WordPressEvidenceConfidence::PublicDeclaration
3128        },
3129        WordPressEvidenceSource::OperatorContext => WordPressEvidenceConfidence::OperatorAssertion,
3130    }
3131}
3132
3133fn versions_conflict(versions: &BTreeSet<WordPressVersionEvidence>) -> bool {
3134    let mut normalized = BTreeSet::new();
3135    let mut unsupported = BTreeSet::new();
3136    for version in versions {
3137        match NumericDottedVersion::parse(&version.value) {
3138            Ok(version) => {
3139                normalized.insert(version);
3140            },
3141            Err(_) => {
3142                unsupported.insert(version.value.as_str());
3143            },
3144        }
3145    }
3146    normalized.len() > 1
3147        || unsupported.len() > 1
3148        || (!normalized.is_empty() && !unsupported.is_empty())
3149}
3150
3151#[derive(Clone)]
3152struct VersionResolutionState {
3153    status: WordPressVersionResolution,
3154    reason: WordPressVersionResolutionReason,
3155    version: Option<ProfiledVersionKey>,
3156}
3157
3158impl VersionResolutionState {
3159    fn missing() -> Self {
3160        Self {
3161            status: WordPressVersionResolution::Missing,
3162            reason: WordPressVersionResolutionReason::NoVersionEvidence,
3163            version: None,
3164        }
3165    }
3166}
3167
3168fn resolve_profiled_versions(
3169    assessment: &WordPressComponentAssessment,
3170    profile: WordPressComparisonProfile,
3171) -> VersionResolutionState {
3172    if assessment.versions.is_empty() {
3173        return VersionResolutionState::missing();
3174    }
3175    let mut supported = None::<ProfiledVersionKey>;
3176    let mut supported_conflict = false;
3177    let mut unsupported = false;
3178    for evidence in &assessment.versions {
3179        match ProfiledVersionKey::parse(profile, &evidence.value) {
3180            Ok(version) => {
3181                if let Some(selected) = supported.as_ref() {
3182                    if selected.compare(&version).ok() != Some(Ordering::Equal) {
3183                        supported_conflict = true;
3184                    }
3185                } else {
3186                    supported = Some(version);
3187                }
3188            },
3189            Err(_) => {
3190                unsupported = true;
3191            },
3192        }
3193    }
3194    if supported_conflict {
3195        return VersionResolutionState {
3196            status: WordPressVersionResolution::Conflicting,
3197            reason: WordPressVersionResolutionReason::ConflictingVersionEvidence,
3198            version: None,
3199        };
3200    }
3201    if unsupported || supported.is_none() {
3202        return VersionResolutionState {
3203            status: WordPressVersionResolution::Unsupported,
3204            reason: WordPressVersionResolutionReason::UnsupportedVersionEvidence,
3205            version: None,
3206        };
3207    }
3208    VersionResolutionState {
3209        status: WordPressVersionResolution::SupportedEquivalent,
3210        reason: if assessment.versions.len() == 1 {
3211            WordPressVersionResolutionReason::SingleSupportedVersion
3212        } else {
3213            WordPressVersionResolutionReason::EquivalentSupportedVersions
3214        },
3215        version: supported,
3216    }
3217}
3218
3219fn profile_independent_component_evidence(
3220    assessment: &WordPressComponentAssessment,
3221) -> WordPressComponentEvidenceClass {
3222    if assessment.identity_sources.iter().any(|source| {
3223        matches!(
3224            source,
3225            WordPressEvidenceSource::GeneratorMetadata
3226                | WordPressEvidenceSource::SameOriginAssetPath
3227                | WordPressEvidenceSource::ThemeStylesheetDeclaration
3228        )
3229    }) {
3230        WordPressComponentEvidenceClass::ObservedHint
3231    } else if assessment
3232        .identity_sources
3233        .contains(&WordPressEvidenceSource::OperatorContext)
3234    {
3235        WordPressComponentEvidenceClass::OperatorSupplied
3236    } else {
3237        WordPressComponentEvidenceClass::Unknown
3238    }
3239}
3240
3241fn evaluate_profiled_version_relation(
3242    resolution: &VersionResolutionState,
3243    record: &WordPressAdvisoryRecord,
3244) -> WordPressVersionRelation {
3245    match resolution.status {
3246        WordPressVersionResolution::Missing | WordPressVersionResolution::Conflicting => {
3247            WordPressVersionRelation::Unknown
3248        },
3249        WordPressVersionResolution::Unsupported => WordPressVersionRelation::Unsupported,
3250        WordPressVersionResolution::SupportedEquivalent => {
3251            let Some(version) = resolution.version.as_ref() else {
3252                return WordPressVersionRelation::Unsupported;
3253            };
3254            if record.profiled_affected_ranges.is_empty() {
3255                return WordPressVersionRelation::Unknown;
3256            }
3257            match record
3258                .profiled_affected_ranges
3259                .iter()
3260                .try_fold(false, |matched, range| {
3261                    range.contains(version).map(|contains| matched || contains)
3262                }) {
3263                Ok(true) => WordPressVersionRelation::WithinDeclaredRange,
3264                Ok(false) => WordPressVersionRelation::OutsideDeclaredRanges,
3265                Err(_) => WordPressVersionRelation::Unsupported,
3266            }
3267        },
3268    }
3269}
3270
3271fn evaluate_version_relation(
3272    assessment: Option<&WordPressComponentAssessment>,
3273    record: &WordPressAdvisoryRecord,
3274) -> WordPressVersionRelation {
3275    let Some(assessment) = assessment else {
3276        return WordPressVersionRelation::Unknown;
3277    };
3278    if assessment.evidence_class == WordPressComponentEvidenceClass::Conflicting {
3279        return WordPressVersionRelation::Unknown;
3280    }
3281    let Some(version) = assessment.versions.first() else {
3282        return WordPressVersionRelation::Unknown;
3283    };
3284    let Ok(version) = NumericDottedVersion::parse(&version.value) else {
3285        return WordPressVersionRelation::Unsupported;
3286    };
3287    if record.affected_ranges.is_empty() {
3288        return WordPressVersionRelation::Unknown;
3289    }
3290    if record
3291        .affected_ranges
3292        .iter()
3293        .any(|range| range.contains(&version))
3294    {
3295        WordPressVersionRelation::WithinDeclaredRange
3296    } else {
3297        WordPressVersionRelation::OutsideDeclaredRanges
3298    }
3299}
3300
3301fn evaluate_prerequisite(
3302    prerequisite: &WordPressPrerequisite,
3303    context: Option<&WordPressContext>,
3304    component: &WordPressComponentIdentity,
3305) -> WordPressPrerequisiteOutcome {
3306    let Some(context) = context else {
3307        return match prerequisite {
3308            WordPressPrerequisite::Unsupported { .. } => WordPressPrerequisiteOutcome::Unsupported,
3309            _ => WordPressPrerequisiteOutcome::Unknown,
3310        };
3311    };
3312    match prerequisite {
3313        WordPressPrerequisite::HostingOs { equals } => {
3314            compare_supplied(context.hosting_os, *equals)
3315        },
3316        WordPressPrerequisite::Multisite { equals } => compare_supplied(context.multisite, *equals),
3317        WordPressPrerequisite::Activation { equals } => {
3318            let supplied = context
3319                .components
3320                .iter()
3321                .find(|candidate| candidate.identity == *component)
3322                .and_then(|candidate| candidate.activation)
3323                .filter(|state| *state != WordPressActivationState::Unknown);
3324            compare_supplied(supplied, *equals)
3325        },
3326        WordPressPrerequisite::Patch { id, equals } => {
3327            let supplied = context
3328                .components
3329                .iter()
3330                .find(|candidate| candidate.identity == *component)
3331                .and_then(|candidate| candidate.patches.iter().find(|patch| patch.id == *id))
3332                .map(|patch| patch.state)
3333                .filter(|state| *state != WordPressPatchState::Unknown);
3334            compare_supplied(supplied, *equals)
3335        },
3336        WordPressPrerequisite::Unsupported { .. } => WordPressPrerequisiteOutcome::Unsupported,
3337    }
3338}
3339
3340fn compare_supplied<T: Eq>(supplied: Option<T>, expected: T) -> WordPressPrerequisiteOutcome {
3341    match supplied {
3342        Some(value) if value == expected => WordPressPrerequisiteOutcome::MatchedOnSuppliedFacts,
3343        Some(_) => WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts,
3344        None => WordPressPrerequisiteOutcome::Unknown,
3345    }
3346}
3347
3348fn summarize_applicability(
3349    component: WordPressComponentEvidenceClass,
3350    version: WordPressVersionRelation,
3351    prerequisites: &[WordPressPrerequisiteEvaluation],
3352) -> WordPressApplicability {
3353    if version == WordPressVersionRelation::OutsideDeclaredRanges
3354        || prerequisites.iter().any(|result| {
3355            result.outcome == WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts
3356        })
3357    {
3358        return WordPressApplicability::ContradictedByDeclaredFacts;
3359    }
3360    if version == WordPressVersionRelation::Unsupported
3361        || prerequisites
3362            .iter()
3363            .any(|result| result.outcome == WordPressPrerequisiteOutcome::Unsupported)
3364    {
3365        return WordPressApplicability::IndeterminateUnsupported;
3366    }
3367    if component == WordPressComponentEvidenceClass::Unknown
3368        || component == WordPressComponentEvidenceClass::Conflicting
3369        || version == WordPressVersionRelation::Unknown
3370        || prerequisites
3371            .iter()
3372            .any(|result| result.outcome == WordPressPrerequisiteOutcome::Unknown)
3373    {
3374        return WordPressApplicability::IndeterminateMissingEvidence;
3375    }
3376    WordPressApplicability::CandidateMatchOnDeclaredFacts
3377}
3378
3379fn validate_patches(
3380    patches: Vec<PatchWire>,
3381) -> Result<Vec<WordPressPatchAssertion>, WordPressReviewError> {
3382    if patches.len() > MAX_WORDPRESS_PATCH_ASSERTIONS_PER_COMPONENT {
3383        return Err(WordPressReviewError::InvalidContext);
3384    }
3385    let mut seen = BTreeSet::new();
3386    let mut validated = Vec::with_capacity(patches.len());
3387    for patch in patches {
3388        if !valid_identifier(&patch.id) {
3389            return Err(WordPressReviewError::InvalidContext);
3390        }
3391        if !seen.insert(patch.id.clone()) {
3392            return Err(WordPressReviewError::InvalidContext);
3393        }
3394        validated.push(WordPressPatchAssertion {
3395            id: patch.id,
3396            state: patch.state.into(),
3397        });
3398    }
3399    validated.sort_by(|left, right| left.id.cmp(&right.id));
3400    Ok(validated)
3401}
3402
3403fn validate_source(source: SourceWire) -> Result<WordPressAdvisorySource, WordPressReviewError> {
3404    validate_identifier(&source.revision)?;
3405    validate_date(&source.retrieved_on)?;
3406    validate_text(&source.usage_basis, 256)?;
3407    if source.reference.len() > MAX_WORDPRESS_REFERENCE_BYTES {
3408        return Err(WordPressReviewError::InvalidCatalog);
3409    }
3410    let url = Url::parse(&source.reference).map_err(|_| WordPressReviewError::InvalidCatalog)?;
3411    if url.scheme() != "https"
3412        || !url.has_host()
3413        || !url.username().is_empty()
3414        || url.password().is_some()
3415    {
3416        return Err(WordPressReviewError::InvalidCatalog);
3417    }
3418    Ok(WordPressAdvisorySource {
3419        reference: source.reference,
3420        revision: source.revision,
3421        retrieved_on: source.retrieved_on,
3422        usage_basis: source.usage_basis,
3423    })
3424}
3425
3426fn validate_range(range: RangeWire) -> Result<WordPressAffectedRange, WordPressReviewError> {
3427    if range.lower.is_none() && range.upper.is_none() {
3428        return Err(WordPressReviewError::InvalidVersionRange);
3429    }
3430    let lower = range.lower.map(validate_endpoint).transpose()?;
3431    let upper = range.upper.map(validate_endpoint).transpose()?;
3432    if let (Some(lower), Some(upper)) = (&lower, &upper) {
3433        match lower.version.cmp(&upper.version) {
3434            Ordering::Greater => return Err(WordPressReviewError::InvalidVersionRange),
3435            Ordering::Equal if !(lower.inclusive && upper.inclusive) => {
3436                return Err(WordPressReviewError::InvalidVersionRange);
3437            },
3438            Ordering::Less | Ordering::Equal => {},
3439        }
3440    }
3441    Ok(WordPressAffectedRange { lower, upper })
3442}
3443
3444fn validate_endpoint(
3445    endpoint: EndpointWire,
3446) -> Result<WordPressVersionEndpoint, WordPressReviewError> {
3447    let version = NumericDottedVersion::parse(&endpoint.version)
3448        .map_err(|_| WordPressReviewError::InvalidVersionRange)?;
3449    Ok(WordPressVersionEndpoint {
3450        declared: endpoint.version,
3451        version,
3452        inclusive: endpoint.inclusive,
3453    })
3454}
3455
3456fn validate_prerequisite(
3457    prerequisite: PrerequisiteWire,
3458) -> Result<WordPressPrerequisite, WordPressReviewError> {
3459    Ok(match prerequisite {
3460        PrerequisiteWire::HostingOs { equals } => WordPressPrerequisite::HostingOs {
3461            equals: equals.into(),
3462        },
3463        PrerequisiteWire::Multisite { equals } => WordPressPrerequisite::Multisite {
3464            equals: equals.into(),
3465        },
3466        PrerequisiteWire::Activation { equals } => {
3467            let equals = equals.into();
3468            if equals == WordPressActivationState::Unknown {
3469                return Err(WordPressReviewError::InvalidCatalog);
3470            }
3471            WordPressPrerequisite::Activation { equals }
3472        },
3473        PrerequisiteWire::Patch { id, equals } => {
3474            validate_identifier(&id)?;
3475            let equals = equals.into();
3476            if equals == WordPressPatchState::Unknown {
3477                return Err(WordPressReviewError::InvalidCatalog);
3478            }
3479            WordPressPrerequisite::Patch { id, equals }
3480        },
3481        PrerequisiteWire::Unsupported { name } => {
3482            validate_identifier(&name)?;
3483            WordPressPrerequisite::Unsupported { name }
3484        },
3485    })
3486}
3487
3488fn validate_root_url(value: &str) -> Result<Url, WordPressReviewError> {
3489    if value.len() > MAX_WORDPRESS_REFERENCE_BYTES {
3490        return Err(WordPressReviewError::InvalidContext);
3491    }
3492    let url = Url::parse(value).map_err(|_| WordPressReviewError::InvalidContext)?;
3493    if !matches!(url.scheme(), "http" | "https")
3494        || !url.has_host()
3495        || !url.username().is_empty()
3496        || url.password().is_some()
3497        || url.query().is_some()
3498        || url.fragment().is_some()
3499        || url.path() != "/"
3500    {
3501        return Err(WordPressReviewError::InvalidContext);
3502    }
3503    Ok(url)
3504}
3505
3506fn validate_source_version(value: &str) -> Result<String, WordPressReviewError> {
3507    if value.is_empty()
3508        || value.len() > MAX_WORDPRESS_VERSION_BYTES
3509        || value.chars().any(char::is_control)
3510    {
3511        return Err(WordPressReviewError::InvalidContext);
3512    }
3513    Ok(value.to_owned())
3514}
3515
3516fn validate_identifier(value: &str) -> Result<(), WordPressReviewError> {
3517    if !valid_identifier(value) {
3518        return Err(WordPressReviewError::InvalidCatalog);
3519    }
3520    Ok(())
3521}
3522
3523fn valid_identifier(value: &str) -> bool {
3524    !value.is_empty()
3525        && value.len() <= MAX_WORDPRESS_IDENTIFIER_BYTES
3526        && value.bytes().all(|byte| {
3527            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'@' | b'-')
3528        })
3529}
3530
3531fn validate_text(value: &str, maximum: usize) -> Result<(), WordPressReviewError> {
3532    if value.is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
3533        return Err(WordPressReviewError::InvalidCatalog);
3534    }
3535    Ok(())
3536}
3537
3538pub(crate) fn valid_slug(value: &str) -> bool {
3539    if value.is_empty() || value.len() > MAX_WORDPRESS_SLUG_BYTES {
3540        return false;
3541    }
3542    let mut previous_hyphen = true;
3543    for byte in value.bytes() {
3544        match byte {
3545            b'a'..=b'z' | b'0'..=b'9' => previous_hyphen = false,
3546            b'-' if !previous_hyphen => previous_hyphen = true,
3547            _ => return false,
3548        }
3549    }
3550    !previous_hyphen
3551}
3552
3553fn valid_cve(value: &str) -> bool {
3554    let Some(rest) = value.strip_prefix("CVE-") else {
3555        return false;
3556    };
3557    let mut parts = rest.split('-');
3558    let (Some(year), Some(sequence), None) = (parts.next(), parts.next(), parts.next()) else {
3559        return false;
3560    };
3561    year.len() == 4
3562        && year.bytes().all(|byte| byte.is_ascii_digit())
3563        && (4..=10).contains(&sequence.len())
3564        && sequence.bytes().all(|byte| byte.is_ascii_digit())
3565}
3566
3567fn validate_date(value: &str) -> Result<(), WordPressReviewError> {
3568    let bytes = value.as_bytes();
3569    if bytes.len() != 10
3570        || bytes[4] != b'-'
3571        || bytes[7] != b'-'
3572        || !bytes
3573            .iter()
3574            .enumerate()
3575            .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
3576    {
3577        return Err(WordPressReviewError::InvalidCatalog);
3578    }
3579    let year = decimal(&bytes[0..4]).ok_or(WordPressReviewError::InvalidCatalog)?;
3580    let month = decimal(&bytes[5..7]).ok_or(WordPressReviewError::InvalidCatalog)?;
3581    let day = decimal(&bytes[8..10]).ok_or(WordPressReviewError::InvalidCatalog)?;
3582    let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
3583    let maximum = match month {
3584        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
3585        4 | 6 | 9 | 11 => 30,
3586        2 if leap => 29,
3587        2 => 28,
3588        _ => return Err(WordPressReviewError::InvalidCatalog),
3589    };
3590    if day == 0 || day > maximum {
3591        return Err(WordPressReviewError::InvalidCatalog);
3592    }
3593    Ok(())
3594}
3595
3596fn decimal(bytes: &[u8]) -> Option<u32> {
3597    bytes.iter().try_fold(0_u32, |value, digit| {
3598        value.checked_mul(10)?.checked_add(u32::from(*digit - b'0'))
3599    })
3600}
3601
3602fn require_schema(value: &Value, expected: &str) -> Result<(), WordPressReviewError> {
3603    if value
3604        .as_object()
3605        .and_then(|object| object.get("schema"))
3606        .and_then(Value::as_str)
3607        != Some(expected)
3608    {
3609        return Err(WordPressReviewError::UnsupportedSchema);
3610    }
3611    Ok(())
3612}
3613
3614fn parse_bounded_json(
3615    bytes: &[u8],
3616    maximum_bytes: usize,
3617    maximum_nodes: usize,
3618    maximum_array_length: usize,
3619    too_large: WordPressReviewError,
3620) -> Result<Value, WordPressReviewError> {
3621    if bytes.is_empty() {
3622        return Err(WordPressReviewError::EmptyInput);
3623    }
3624    if bytes.len() > maximum_bytes {
3625        return Err(too_large);
3626    }
3627    let limits = JsonLimits {
3628        maximum_nodes,
3629        maximum_array_length,
3630    };
3631    let mut budget = JsonBudget::default();
3632    let mut decoder = serde_json::Deserializer::from_slice(bytes);
3633    let parsed = BoundedValueSeed {
3634        depth: 0,
3635        limits,
3636        budget: &mut budget,
3637    }
3638    .deserialize(&mut decoder)
3639    .map_err(classify_json_error)?;
3640    decoder.end().map_err(classify_json_error)?;
3641    Ok(parsed)
3642}
3643
3644fn classify_json_error(error: serde_json::Error) -> WordPressReviewError {
3645    let message = error.to_string();
3646    if message.contains(DUPLICATE_KEY_MARKER) {
3647        WordPressReviewError::DuplicateKey
3648    } else if message.contains(JSON_LIMIT_MARKER) {
3649        WordPressReviewError::JsonLimitExceeded
3650    } else {
3651        WordPressReviewError::MalformedJson
3652    }
3653}
3654
3655#[derive(Clone, Copy)]
3656struct JsonLimits {
3657    maximum_nodes: usize,
3658    maximum_array_length: usize,
3659}
3660
3661#[derive(Default)]
3662struct JsonBudget {
3663    nodes: usize,
3664}
3665
3666struct BoundedValueSeed<'a> {
3667    depth: usize,
3668    limits: JsonLimits,
3669    budget: &'a mut JsonBudget,
3670}
3671
3672impl<'de> DeserializeSeed<'de> for BoundedValueSeed<'_> {
3673    type Value = Value;
3674
3675    fn deserialize<D: de::Deserializer<'de>>(self, decoder: D) -> Result<Value, D::Error> {
3676        if self.depth > MAX_JSON_DEPTH || self.budget.nodes == self.limits.maximum_nodes {
3677            return Err(de::Error::custom(JSON_LIMIT_MARKER));
3678        }
3679        self.budget.nodes += 1;
3680        decoder.deserialize_any(BoundedValueVisitor {
3681            depth: self.depth,
3682            limits: self.limits,
3683            budget: self.budget,
3684        })
3685    }
3686}
3687
3688struct BoundedValueVisitor<'a> {
3689    depth: usize,
3690    limits: JsonLimits,
3691    budget: &'a mut JsonBudget,
3692}
3693
3694impl<'de> Visitor<'de> for BoundedValueVisitor<'_> {
3695    type Value = Value;
3696
3697    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3698        formatter.write_str("bounded WordPress JSON")
3699    }
3700
3701    fn visit_bool<E: de::Error>(self, value: bool) -> Result<Value, E> {
3702        Ok(Value::Bool(value))
3703    }
3704
3705    fn visit_unit<E: de::Error>(self) -> Result<Value, E> {
3706        Ok(Value::Null)
3707    }
3708
3709    fn visit_u64<E: de::Error>(self, value: u64) -> Result<Value, E> {
3710        Ok(Value::Number(Number::from(value)))
3711    }
3712
3713    fn visit_i64<E: de::Error>(self, value: i64) -> Result<Value, E> {
3714        Ok(Value::Number(Number::from(value)))
3715    }
3716
3717    fn visit_f64<E: de::Error>(self, value: f64) -> Result<Value, E> {
3718        Number::from_f64(value)
3719            .map(Value::Number)
3720            .ok_or_else(|| de::Error::custom("non-finite number"))
3721    }
3722
3723    fn visit_borrowed_str<E: de::Error>(self, value: &'de str) -> Result<Value, E> {
3724        bounded_string(value)
3725    }
3726
3727    fn visit_str<E: de::Error>(self, value: &str) -> Result<Value, E> {
3728        bounded_string(value)
3729    }
3730
3731    fn visit_string<E: de::Error>(self, value: String) -> Result<Value, E> {
3732        if value.len() > MAX_JSON_STRING_BYTES {
3733            Err(de::Error::custom(JSON_LIMIT_MARKER))
3734        } else {
3735            Ok(Value::String(value))
3736        }
3737    }
3738
3739    fn visit_seq<A: SeqAccess<'de>>(self, mut values: A) -> Result<Value, A::Error> {
3740        let mut array = Vec::new();
3741        let depth = self.depth;
3742        let limits = self.limits;
3743        let budget = self.budget;
3744        loop {
3745            if array.len() == limits.maximum_array_length {
3746                if values.next_element::<de::IgnoredAny>()?.is_some() {
3747                    return Err(de::Error::custom(JSON_LIMIT_MARKER));
3748                }
3749                break;
3750            }
3751            let Some(value) = values.next_element_seed(BoundedValueSeed {
3752                depth: depth + 1,
3753                limits,
3754                budget: &mut *budget,
3755            })?
3756            else {
3757                break;
3758            };
3759            array.push(value);
3760        }
3761        Ok(Value::Array(array))
3762    }
3763
3764    fn visit_map<A: MapAccess<'de>>(self, mut fields: A) -> Result<Value, A::Error> {
3765        let mut object = Map::new();
3766        let depth = self.depth;
3767        let limits = self.limits;
3768        let budget = self.budget;
3769        loop {
3770            if object.len() == MAX_JSON_OBJECT_FIELDS {
3771                if fields.next_key_seed(BoundedKeySeed)?.is_some() {
3772                    return Err(de::Error::custom(JSON_LIMIT_MARKER));
3773                }
3774                break;
3775            }
3776            let Some(key) = fields.next_key_seed(BoundedKeySeed)? else {
3777                break;
3778            };
3779            if object.contains_key(&key) {
3780                return Err(de::Error::custom(DUPLICATE_KEY_MARKER));
3781            }
3782            let value = fields.next_value_seed(BoundedValueSeed {
3783                depth: depth + 1,
3784                limits,
3785                budget: &mut *budget,
3786            })?;
3787            object.insert(key, value);
3788        }
3789        Ok(Value::Object(object))
3790    }
3791}
3792
3793fn bounded_string<E: de::Error>(value: &str) -> Result<Value, E> {
3794    if value.len() > MAX_JSON_STRING_BYTES {
3795        Err(de::Error::custom(JSON_LIMIT_MARKER))
3796    } else {
3797        Ok(Value::String(value.to_owned()))
3798    }
3799}
3800
3801struct BoundedKeySeed;
3802
3803impl<'de> DeserializeSeed<'de> for BoundedKeySeed {
3804    type Value = String;
3805
3806    fn deserialize<D: de::Deserializer<'de>>(self, decoder: D) -> Result<String, D::Error> {
3807        decoder.deserialize_str(BoundedKeyVisitor)
3808    }
3809}
3810
3811struct BoundedKeyVisitor;
3812
3813impl Visitor<'_> for BoundedKeyVisitor {
3814    type Value = String;
3815
3816    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3817        formatter.write_str("a bounded JSON object key")
3818    }
3819
3820    fn visit_borrowed_str<E: de::Error>(self, value: &str) -> Result<String, E> {
3821        self.visit_str(value)
3822    }
3823
3824    fn visit_str<E: de::Error>(self, value: &str) -> Result<String, E> {
3825        if value.len() > MAX_JSON_KEY_BYTES {
3826            Err(de::Error::custom(JSON_LIMIT_MARKER))
3827        } else {
3828            Ok(value.to_owned())
3829        }
3830    }
3831
3832    fn visit_string<E: de::Error>(self, value: String) -> Result<String, E> {
3833        if value.len() > MAX_JSON_KEY_BYTES {
3834            Err(de::Error::custom(JSON_LIMIT_MARKER))
3835        } else {
3836            Ok(value)
3837        }
3838    }
3839}
3840
3841#[derive(Deserialize)]
3842#[serde(deny_unknown_fields)]
3843struct ContextWire {
3844    #[allow(dead_code)]
3845    schema: String,
3846    root: String,
3847    #[serde(default)]
3848    hosting_os: Option<HostingOsWire>,
3849    #[serde(default)]
3850    multisite: Option<MultisiteWire>,
3851    components: Vec<ContextComponentWire>,
3852}
3853
3854#[derive(Deserialize)]
3855#[serde(deny_unknown_fields)]
3856struct ContextComponentWire {
3857    kind: ComponentKindWire,
3858    slug: String,
3859    #[serde(default)]
3860    version: Option<String>,
3861    #[serde(default)]
3862    activation: Option<ActivationWire>,
3863    #[serde(default)]
3864    patches: Vec<PatchWire>,
3865}
3866
3867#[derive(Deserialize)]
3868#[serde(deny_unknown_fields)]
3869struct PatchWire {
3870    id: String,
3871    state: PatchStateWire,
3872}
3873
3874#[derive(Deserialize)]
3875#[serde(deny_unknown_fields)]
3876struct CatalogV1Wire {
3877    #[allow(dead_code)]
3878    schema: String,
3879    catalog: CatalogMetadataWire,
3880    records: Vec<AdvisoryRecordV1Wire>,
3881}
3882
3883fn validate_profiled_range(
3884    range: RangeWire,
3885    profile: WordPressComparisonProfile,
3886) -> Result<WordPressProfiledAffectedRange, WordPressReviewError> {
3887    if range.lower.is_none() && range.upper.is_none() {
3888        return Err(WordPressReviewError::InvalidVersionRange);
3889    }
3890    let lower = range
3891        .lower
3892        .map(|endpoint| validate_profiled_endpoint(endpoint, profile))
3893        .transpose()?;
3894    let upper = range
3895        .upper
3896        .map(|endpoint| validate_profiled_endpoint(endpoint, profile))
3897        .transpose()?;
3898    if let (Some(lower), Some(upper)) = (&lower, &upper) {
3899        match lower
3900            .version
3901            .compare(&upper.version)
3902            .map_err(|_| WordPressReviewError::InvalidVersionRange)?
3903        {
3904            Ordering::Greater => return Err(WordPressReviewError::InvalidVersionRange),
3905            Ordering::Equal if !(lower.inclusive && upper.inclusive) => {
3906                return Err(WordPressReviewError::InvalidVersionRange);
3907            },
3908            Ordering::Less | Ordering::Equal => {},
3909        }
3910    }
3911    Ok(WordPressProfiledAffectedRange { lower, upper })
3912}
3913
3914fn validate_profiled_endpoint(
3915    endpoint: EndpointWire,
3916    profile: WordPressComparisonProfile,
3917) -> Result<WordPressProfiledVersionEndpoint, WordPressReviewError> {
3918    let version = ProfiledVersionKey::parse(profile, &endpoint.version)
3919        .map_err(|_| WordPressReviewError::InvalidVersionRange)?;
3920    Ok(WordPressProfiledVersionEndpoint {
3921        declared: endpoint.version,
3922        version,
3923        inclusive: endpoint.inclusive,
3924    })
3925}
3926
3927fn compare_profiled_ranges(
3928    left: &WordPressProfiledAffectedRange,
3929    right: &WordPressProfiledAffectedRange,
3930) -> Ordering {
3931    compare_profiled_endpoint_semantics(left.lower.as_ref(), right.lower.as_ref())
3932        .then_with(|| {
3933            compare_profiled_endpoint_semantics(left.upper.as_ref(), right.upper.as_ref())
3934        })
3935        .then_with(|| {
3936            compare_profiled_endpoint_spellings(left.lower.as_ref(), right.lower.as_ref())
3937        })
3938        .then_with(|| {
3939            compare_profiled_endpoint_spellings(left.upper.as_ref(), right.upper.as_ref())
3940        })
3941}
3942
3943fn compare_profiled_endpoint_semantics(
3944    left: Option<&WordPressProfiledVersionEndpoint>,
3945    right: Option<&WordPressProfiledVersionEndpoint>,
3946) -> Ordering {
3947    match (left, right) {
3948        (None, None) => Ordering::Equal,
3949        (None, Some(_)) => Ordering::Less,
3950        (Some(_), None) => Ordering::Greater,
3951        (Some(left), Some(right)) => left
3952            .version
3953            .compare(&right.version)
3954            .expect("one record has exactly one validated comparison profile")
3955            .then_with(|| left.inclusive.cmp(&right.inclusive)),
3956    }
3957}
3958
3959fn compare_profiled_endpoint_spellings(
3960    left: Option<&WordPressProfiledVersionEndpoint>,
3961    right: Option<&WordPressProfiledVersionEndpoint>,
3962) -> Ordering {
3963    match (left, right) {
3964        (None, None) => Ordering::Equal,
3965        (None, Some(_)) => Ordering::Less,
3966        (Some(_), None) => Ordering::Greater,
3967        (Some(left), Some(right)) => left.declared.cmp(&right.declared),
3968    }
3969}
3970
3971fn profiled_ranges_equivalent(
3972    left: &WordPressProfiledAffectedRange,
3973    right: &WordPressProfiledAffectedRange,
3974) -> bool {
3975    profiled_endpoints_equivalent(left.lower.as_ref(), right.lower.as_ref())
3976        && profiled_endpoints_equivalent(left.upper.as_ref(), right.upper.as_ref())
3977}
3978
3979fn profiled_endpoints_equivalent(
3980    left: Option<&WordPressProfiledVersionEndpoint>,
3981    right: Option<&WordPressProfiledVersionEndpoint>,
3982) -> bool {
3983    match (left, right) {
3984        (None, None) => true,
3985        (Some(left), Some(right)) => {
3986            left.inclusive == right.inclusive
3987                && left.version.compare(&right.version).ok() == Some(Ordering::Equal)
3988        },
3989        (None, Some(_)) | (Some(_), None) => false,
3990    }
3991}
3992
3993#[derive(Deserialize)]
3994#[serde(deny_unknown_fields)]
3995struct CatalogV2Wire {
3996    #[allow(dead_code)]
3997    schema: String,
3998    catalog: CatalogMetadataWire,
3999    records: Vec<AdvisoryRecordV2Wire>,
4000}
4001
4002#[derive(Deserialize)]
4003#[serde(deny_unknown_fields)]
4004struct CatalogMetadataWire {
4005    id: String,
4006    revision: String,
4007    retrieved_on: String,
4008}
4009
4010#[derive(Deserialize)]
4011#[serde(deny_unknown_fields)]
4012struct AdvisoryRecordV1Wire {
4013    id: String,
4014    component: ComponentIdentityWire,
4015    source: SourceWire,
4016    #[serde(default)]
4017    cve: Option<String>,
4018    summary: String,
4019    #[serde(default)]
4020    affected_ranges: Vec<RangeWire>,
4021    #[serde(default)]
4022    fixed_versions: Vec<String>,
4023    #[serde(default)]
4024    prerequisites: Vec<PrerequisiteWire>,
4025    #[serde(default)]
4026    remediation: Option<String>,
4027}
4028
4029#[derive(Deserialize)]
4030#[serde(deny_unknown_fields)]
4031struct AdvisoryRecordV2Wire {
4032    id: String,
4033    component: ComponentIdentityWire,
4034    source: SourceWire,
4035    comparison_profile: String,
4036    #[serde(default)]
4037    cve: Option<String>,
4038    summary: String,
4039    #[serde(default)]
4040    affected_ranges: Vec<RangeWire>,
4041    #[serde(default)]
4042    fixed_versions: Vec<String>,
4043    #[serde(default)]
4044    prerequisites: Vec<PrerequisiteWire>,
4045    #[serde(default)]
4046    remediation: Option<String>,
4047}
4048
4049struct AdvisoryRecordInput {
4050    id: String,
4051    component: ComponentIdentityWire,
4052    source: SourceWire,
4053    comparison_profile: WordPressComparisonProfile,
4054    cve: Option<String>,
4055    summary: String,
4056    affected_ranges: Vec<RangeWire>,
4057    fixed_versions: Vec<String>,
4058    prerequisites: Vec<PrerequisiteWire>,
4059    remediation: Option<String>,
4060}
4061
4062impl From<AdvisoryRecordV1Wire> for AdvisoryRecordInput {
4063    fn from(record: AdvisoryRecordV1Wire) -> Self {
4064        Self {
4065            id: record.id,
4066            component: record.component,
4067            source: record.source,
4068            comparison_profile: WordPressComparisonProfile::NumericDottedV1,
4069            cve: record.cve,
4070            summary: record.summary,
4071            affected_ranges: record.affected_ranges,
4072            fixed_versions: record.fixed_versions,
4073            prerequisites: record.prerequisites,
4074            remediation: record.remediation,
4075        }
4076    }
4077}
4078
4079impl TryFrom<AdvisoryRecordV2Wire> for AdvisoryRecordInput {
4080    type Error = WordPressReviewError;
4081
4082    fn try_from(record: AdvisoryRecordV2Wire) -> Result<Self, Self::Error> {
4083        Ok(Self {
4084            id: record.id,
4085            component: record.component,
4086            source: record.source,
4087            comparison_profile: WordPressComparisonProfile::parse(&record.comparison_profile)
4088                .map_err(|_| WordPressReviewError::InvalidCatalog)?,
4089            cve: record.cve,
4090            summary: record.summary,
4091            affected_ranges: record.affected_ranges,
4092            fixed_versions: record.fixed_versions,
4093            prerequisites: record.prerequisites,
4094            remediation: record.remediation,
4095        })
4096    }
4097}
4098
4099#[derive(Deserialize)]
4100#[serde(deny_unknown_fields)]
4101struct ComponentIdentityWire {
4102    kind: ComponentKindWire,
4103    slug: String,
4104}
4105
4106#[derive(Deserialize)]
4107#[serde(deny_unknown_fields)]
4108struct SourceWire {
4109    reference: String,
4110    revision: String,
4111    retrieved_on: String,
4112    usage_basis: String,
4113}
4114
4115#[derive(Deserialize)]
4116#[serde(deny_unknown_fields)]
4117struct RangeWire {
4118    #[serde(default)]
4119    lower: Option<EndpointWire>,
4120    #[serde(default)]
4121    upper: Option<EndpointWire>,
4122}
4123
4124#[derive(Deserialize)]
4125#[serde(deny_unknown_fields)]
4126struct EndpointWire {
4127    version: String,
4128    inclusive: bool,
4129}
4130
4131#[derive(Deserialize)]
4132#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
4133enum PrerequisiteWire {
4134    HostingOs { equals: HostingOsWire },
4135    Multisite { equals: MultisiteWire },
4136    Activation { equals: ActivationWire },
4137    Patch { id: String, equals: PatchStateWire },
4138    Unsupported { name: String },
4139}
4140
4141#[derive(Clone, Copy, Deserialize)]
4142#[serde(rename_all = "snake_case")]
4143enum ComponentKindWire {
4144    Core,
4145    Plugin,
4146    Theme,
4147}
4148
4149impl From<ComponentKindWire> for WordPressComponentKind {
4150    fn from(value: ComponentKindWire) -> Self {
4151        match value {
4152            ComponentKindWire::Core => Self::Core,
4153            ComponentKindWire::Plugin => Self::Plugin,
4154            ComponentKindWire::Theme => Self::Theme,
4155        }
4156    }
4157}
4158
4159#[derive(Clone, Copy, Deserialize)]
4160#[serde(rename_all = "snake_case")]
4161enum HostingOsWire {
4162    Linux,
4163    Windows,
4164    Macos,
4165    Bsd,
4166    Other,
4167}
4168
4169impl From<HostingOsWire> for WordPressHostingOs {
4170    fn from(value: HostingOsWire) -> Self {
4171        match value {
4172            HostingOsWire::Linux => Self::Linux,
4173            HostingOsWire::Windows => Self::Windows,
4174            HostingOsWire::Macos => Self::Macos,
4175            HostingOsWire::Bsd => Self::Bsd,
4176            HostingOsWire::Other => Self::Other,
4177        }
4178    }
4179}
4180
4181#[derive(Clone, Copy, Deserialize)]
4182#[serde(rename_all = "snake_case")]
4183enum MultisiteWire {
4184    Enabled,
4185    Disabled,
4186}
4187
4188impl From<MultisiteWire> for WordPressMultisiteState {
4189    fn from(value: MultisiteWire) -> Self {
4190        match value {
4191            MultisiteWire::Enabled => Self::Enabled,
4192            MultisiteWire::Disabled => Self::Disabled,
4193        }
4194    }
4195}
4196
4197#[derive(Clone, Copy, Deserialize)]
4198#[serde(rename_all = "snake_case")]
4199enum ActivationWire {
4200    Active,
4201    Inactive,
4202    NetworkActive,
4203    Unknown,
4204}
4205
4206impl From<ActivationWire> for WordPressActivationState {
4207    fn from(value: ActivationWire) -> Self {
4208        match value {
4209            ActivationWire::Active => Self::Active,
4210            ActivationWire::Inactive => Self::Inactive,
4211            ActivationWire::NetworkActive => Self::NetworkActive,
4212            ActivationWire::Unknown => Self::Unknown,
4213        }
4214    }
4215}
4216
4217#[derive(Clone, Copy, Deserialize)]
4218#[serde(rename_all = "snake_case")]
4219enum PatchStateWire {
4220    Applied,
4221    NotApplied,
4222    Unknown,
4223}
4224
4225impl From<PatchStateWire> for WordPressPatchState {
4226    fn from(value: PatchStateWire) -> Self {
4227        match value {
4228            PatchStateWire::Applied => Self::Applied,
4229            PatchStateWire::NotApplied => Self::NotApplied,
4230            PatchStateWire::Unknown => Self::Unknown,
4231        }
4232    }
4233}
4234
4235#[cfg(test)]
4236mod tests {
4237    use super::*;
4238
4239    fn wordfence_associations(count: usize) -> String {
4240        use std::fmt::Write as _;
4241
4242        let mut document = String::from("{");
4243        for index in 0..count {
4244            if index != 0 {
4245                document.push(',');
4246            }
4247            let id = format!("00000000-0000-4000-8000-{index:012x}");
4248            write!(
4249                document,
4250                r#""{id}":{{"id":"{id}","title":"Synthetic","software":[{{"type":"plugin","name":"Synthetic","slug":"selected-plugin","affected_versions":{{"all":{{"from_version":"*","from_inclusive":false,"to_version":"*","to_inclusive":false}}}},"patched":false,"patched_versions":[],"remediation":"Review source guidance"}}],"informational":false,"description":"Synthetic bounded evaluator fixture","references":["https://example.invalid/{id}"],"cwe":null,"cvss":null,"cve":null,"cve_link":null,"researchers":[],"published":null,"updated":null,"copyrights":null}}"#
4251            )
4252            .unwrap();
4253        }
4254        document.push('}');
4255        document
4256    }
4257
4258    fn wordfence_dense_profiled_associations(count: usize) -> String {
4259        let ranges = (0..MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION)
4260            .map(|index| {
4261                (
4262                    format!("range-{index:02}"),
4263                    serde_json::json!({
4264                        "from_version":format!("1.{index}"),
4265                        "from_inclusive":true,
4266                        "to_version":format!("1.{index}.1"),
4267                        "to_inclusive":true
4268                    }),
4269                )
4270            })
4271            .collect::<serde_json::Map<_, _>>();
4272        let patched_versions = (0..MAX_WORDFENCE_V3_PATCHED_VERSIONS)
4273            .map(|index| format!("9.{index}"))
4274            .collect::<Vec<_>>();
4275        let mut records = serde_json::Map::new();
4276        for index in 0..count {
4277            let id = format!("00000000-0000-4000-8000-{index:012x}");
4278            records.insert(
4279                id.clone(),
4280                serde_json::json!({
4281                    "id":id,
4282                    "title":"[SYNTHETIC] Dense work fixture",
4283                    "software":[{
4284                        "type":"plugin",
4285                        "name":"[SYNTHETIC] Dense component",
4286                        "slug":"selected-plugin",
4287                        "affected_versions":ranges.clone(),
4288                        "patched":true,
4289                        "patched_versions":patched_versions.clone(),
4290                        "remediation":"Review source guidance"
4291                    }],
4292                    "informational":false,
4293                    "description":"Synthetic Cartesian-work fixture",
4294                    "references":[format!("https://example.invalid/{index}")],
4295                    "cwe":null,
4296                    "cvss":null,
4297                    "cve":null,
4298                    "cve_link":null,
4299                    "researchers":[],
4300                    "published":null,
4301                    "updated":null,
4302                    "copyrights":null
4303                }),
4304            );
4305        }
4306        serde_json::Value::Object(records).to_string()
4307    }
4308
4309    const CONTEXT: &str = r#"{
4310      "schema":"security.wordpress-context/v1",
4311      "root":"https://example.test/",
4312      "hosting_os":"linux",
4313      "multisite":"enabled",
4314      "components":[
4315        {"kind":"core","slug":"wordpress","version":"6.9.4","activation":"active"},
4316        {"kind":"plugin","slug":"sample-plugin","version":"2.4.0","activation":"network_active",
4317         "patches":[{"id":"vendor-backport-1","state":"applied"}]},
4318        {"kind":"theme","slug":"sample-theme","activation":"inactive"}
4319      ]
4320    }"#;
4321
4322    const CATALOG: &str = r#"{
4323      "schema":"security.wordpress-advisory-catalog/v1",
4324      "catalog":{"id":"synthetic-wordpress-catalog","revision":"r1","retrieved_on":"2026-09-06"},
4325      "records":[
4326        {
4327          "id":"SYNTHETIC-CORE-1",
4328          "component":{"kind":"core","slug":"wordpress"},
4329          "source":{"reference":"https://example.test/advisories/core-1","revision":"r1","retrieved_on":"2026-09-06","usage_basis":"synthetic test record"},
4330          "cve":"CVE-2099-1000",
4331          "summary":"Synthetic bounded core record",
4332          "affected_ranges":[
4333            {"lower":{"version":"6.9.0","inclusive":true},"upper":{"version":"6.9.5","inclusive":false}},
4334            {"lower":{"version":"7.0.0","inclusive":true},"upper":{"version":"7.0.2","inclusive":false}}
4335          ],
4336          "fixed_versions":["6.9.5","7.0.2"],
4337          "prerequisites":[{"kind":"hosting_os","equals":"linux"},{"kind":"multisite","equals":"enabled"}],
4338          "remediation":"Follow the cited vendor guidance"
4339        },
4340        {
4341          "id":"SYNTHETIC-PLUGIN-1",
4342          "component":{"kind":"plugin","slug":"sample-plugin"},
4343          "source":{"reference":"https://example.test/advisories/plugin-1","revision":"r2","retrieved_on":"2026-09-06","usage_basis":"synthetic test record"},
4344          "summary":"Synthetic plugin record",
4345          "affected_ranges":[{"upper":{"version":"2.5.0","inclusive":false}}],
4346          "fixed_versions":["2.5.0"],
4347          "prerequisites":[{"kind":"activation","equals":"network_active"},{"kind":"patch","id":"vendor-backport-1","equals":"not_applied"}]
4348        }
4349      ]
4350    }"#;
4351
4352    const PROFILED_CONTEXT: &str = r#"{
4353      "schema":"security.wordpress-context/v1",
4354      "root":"https://example.test/",
4355      "hosting_os":"linux",
4356      "components":[
4357        {"kind":"core","slug":"wordpress","version":"2.4.0-beta1","activation":"active"}
4358      ]
4359    }"#;
4360
4361    const PROFILED_CATALOG: &str = r#"{
4362      "schema":"security.wordpress-advisory-catalog/v2",
4363      "catalog":{"id":"synthetic-wordpress-profiled-catalog","revision":"r2","retrieved_on":"2026-09-07"},
4364      "records":[
4365        {
4366          "id":"SYNTHETIC-NUMERIC-BETA",
4367          "component":{"kind":"core","slug":"wordpress"},
4368          "source":{"reference":"https://example.test/advisories/numeric-beta","revision":"r2","retrieved_on":"2026-09-07","usage_basis":"fictional numeric-profile test record"},
4369          "comparison_profile":"numeric-dotted/v1",
4370          "summary":"Synthetic numeric profile record",
4371          "affected_ranges":[{"lower":{"version":"2.0.0","inclusive":true},"upper":{"version":"3.0.0","inclusive":false}}],
4372          "fixed_versions":["3.0.0"]
4373        },
4374        {
4375          "id":"SYNTHETIC-PHP-BETA",
4376          "component":{"kind":"core","slug":"wordpress"},
4377          "source":{"reference":"https://example.test/advisories/php-beta","revision":"r2","retrieved_on":"2026-09-07","usage_basis":"fictional PHP-subset test record"},
4378          "comparison_profile":"php-release-subset/v1",
4379          "summary":"Synthetic PHP release-subset profile record",
4380          "affected_ranges":[{"lower":{"version":"2.4.0-beta0","inclusive":true},"upper":{"version":"2.4.0","inclusive":false}}],
4381          "fixed_versions":["2.4.0"],
4382          "prerequisites":[{"kind":"hosting_os","equals":"linux"}]
4383        }
4384      ]
4385    }"#;
4386
4387    fn inputs() -> WordPressReviewInputs {
4388        WordPressReviewInputs::new(
4389            Some(parse_wordpress_context(CONTEXT.as_bytes()).unwrap()),
4390            Some(parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()),
4391        )
4392    }
4393
4394    #[test]
4395    fn committed_context_and_catalog_examples_match_the_strict_v1_contract() {
4396        let context = parse_wordpress_context(include_bytes!(
4397            "../../../docs/examples/wordpress-review/context.synthetic.json"
4398        ))
4399        .unwrap();
4400        let curated = parse_wordpress_advisory_catalog(include_bytes!(
4401            "../../../docs/examples/wordpress-review/advisories.curated.json"
4402        ))
4403        .unwrap();
4404        let synthetic = parse_wordpress_advisory_catalog(include_bytes!(
4405            "../../../docs/examples/wordpress-review/advisories.synthetic.json"
4406        ))
4407        .unwrap();
4408
4409        assert_eq!(context.components().len(), 4);
4410        assert_eq!(curated.records().len(), 1);
4411        assert_eq!(curated.records()[0].id(), "GHSA-fpp7-x2x2-2mjf");
4412        assert_eq!(synthetic.records().len(), 3);
4413        assert!(synthetic
4414            .records()
4415            .iter()
4416            .all(|record| record.id().starts_with("SYNTHETIC-")));
4417    }
4418
4419    #[test]
4420    fn context_parser_retains_typed_assertions_and_exact_root() {
4421        let context = parse_wordpress_context(CONTEXT.as_bytes()).unwrap();
4422        assert_eq!(context.root_url().as_str(), "https://example.test/");
4423        assert_eq!(context.hosting_os(), Some(WordPressHostingOs::Linux));
4424        assert_eq!(context.multisite(), Some(WordPressMultisiteState::Enabled));
4425        assert_eq!(context.components().len(), 3);
4426        assert_eq!(context.components()[1].identity().slug(), "sample-plugin");
4427        assert_eq!(
4428            context.components()[1].patches()[0].id(),
4429            "vendor-backport-1"
4430        );
4431    }
4432
4433    #[test]
4434    fn context_rejects_duplicate_keys_identities_and_non_root_authority() {
4435        let duplicate_key = CONTEXT.replacen(
4436            "\"root\":\"https://example.test/\"",
4437            "\"root\":\"https://example.test/\",\"root\":\"https://other.test/\"",
4438            1,
4439        );
4440        assert_eq!(
4441            parse_wordpress_context(duplicate_key.as_bytes()),
4442            Err(WordPressReviewError::DuplicateKey)
4443        );
4444        let duplicate_component = CONTEXT.replace(
4445            "{\"kind\":\"theme\",\"slug\":\"sample-theme\",\"activation\":\"inactive\"}",
4446            "{\"kind\":\"core\",\"slug\":\"wordpress\",\"version\":\"6.9.4\"}",
4447        );
4448        assert_eq!(
4449            parse_wordpress_context(duplicate_component.as_bytes()),
4450            Err(WordPressReviewError::DuplicateComponent)
4451        );
4452        for root in [
4453            "https://user@example.test/",
4454            "https://example.test/subpath",
4455            "https://example.test/?query=value",
4456            "file:///tmp/site",
4457        ] {
4458            let document = CONTEXT.replace("https://example.test/", root);
4459            assert!(matches!(
4460                parse_wordpress_context(document.as_bytes()),
4461                Err(WordPressReviewError::InvalidContext)
4462            ));
4463        }
4464    }
4465
4466    #[test]
4467    fn context_limits_are_enforced_before_model_construction() {
4468        assert_eq!(
4469            parse_wordpress_context(&vec![b' '; MAX_WORDPRESS_CONTEXT_BYTES + 1]),
4470            Err(WordPressReviewError::ContextTooLarge)
4471        );
4472        let components = (0..=MAX_WORDPRESS_CONTEXT_COMPONENTS)
4473            .map(|index| format!(r#"{{"kind":"plugin","slug":"plugin-{index}"}}"#))
4474            .collect::<Vec<_>>()
4475            .join(",");
4476        let document = format!(
4477            r#"{{"schema":"{WORDPRESS_CONTEXT_SCHEMA}","root":"https://example.test/","components":[{components}]}}"#
4478        );
4479        assert!(matches!(
4480            parse_wordpress_context(document.as_bytes()),
4481            Err(WordPressReviewError::JsonLimitExceeded | WordPressReviewError::InvalidContext)
4482        ));
4483    }
4484
4485    #[test]
4486    fn catalog_parser_retains_disjoint_ranges_and_source_basis() {
4487        let catalog = parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap();
4488        assert_eq!(catalog.metadata().id(), "synthetic-wordpress-catalog");
4489        assert_eq!(catalog.records().len(), 2);
4490        assert_eq!(catalog.records()[0].affected_ranges().len(), 2);
4491        assert_eq!(
4492            catalog.records()[0].source().reference(),
4493            "https://example.test/advisories/core-1"
4494        );
4495        assert_eq!(catalog.records()[0].fixed_versions(), ["6.9.5", "7.0.2"]);
4496    }
4497
4498    #[test]
4499    fn catalog_v2_requires_one_explicit_supported_profile_per_record() {
4500        let catalog = parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes()).unwrap();
4501        assert_eq!(catalog.schema(), WordPressAdvisoryCatalogSchema::V2);
4502        assert_eq!(catalog.records().len(), 2);
4503        assert_eq!(
4504            catalog.records()[0].comparison_profile(),
4505            WordPressComparisonProfile::NumericDottedV1
4506        );
4507        assert_eq!(
4508            catalog.records()[1].comparison_profile(),
4509            WordPressComparisonProfile::PhpReleaseSubsetV1
4510        );
4511        assert!(catalog
4512            .records()
4513            .iter()
4514            .all(|record| record.affected_ranges().is_empty()));
4515        assert!(catalog
4516            .records()
4517            .iter()
4518            .all(|record| record.profiled_affected_ranges().len() == 1));
4519
4520        let v1_with_profile = CATALOG.replacen(
4521            "\"summary\":\"Synthetic bounded core record\"",
4522            "\"comparison_profile\":\"numeric-dotted/v1\",\"summary\":\"Synthetic bounded core record\"",
4523            1,
4524        );
4525        assert_eq!(
4526            parse_wordpress_advisory_catalog(v1_with_profile.as_bytes()),
4527            Err(WordPressReviewError::InvalidCatalog)
4528        );
4529
4530        let v2_without_profile =
4531            PROFILED_CATALOG.replacen("\"comparison_profile\":\"numeric-dotted/v1\",", "", 1);
4532        assert_eq!(
4533            parse_wordpress_advisory_catalog(v2_without_profile.as_bytes()),
4534            Err(WordPressReviewError::InvalidCatalog)
4535        );
4536
4537        let v2_unknown_profile = PROFILED_CATALOG.replacen("numeric-dotted/v1", "semver/v1", 1);
4538        assert_eq!(
4539            parse_wordpress_advisory_catalog(v2_unknown_profile.as_bytes()),
4540            Err(WordPressReviewError::InvalidCatalog)
4541        );
4542    }
4543
4544    #[test]
4545    fn profiled_evaluation_interprets_beta_only_under_the_selected_profile() {
4546        let inputs = WordPressReviewInputs::new(
4547            Some(parse_wordpress_context(PROFILED_CONTEXT.as_bytes()).unwrap()),
4548            Some(parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes()).unwrap()),
4549        );
4550        let signals = [WordPressComponentSignal::generator_metadata(Some("2.4.0-beta1")).unwrap()];
4551        let result = evaluate_wordpress_review(&signals, &inputs).unwrap();
4552        assert!(result.components().iter().all(|component| {
4553            component.evidence_class() != WordPressComponentEvidenceClass::Conflicting
4554        }));
4555
4556        assert_eq!(
4557            result.catalog_schema(),
4558            Some(WordPressAdvisoryCatalogSchema::V2)
4559        );
4560        let numeric = result
4561            .advisories()
4562            .iter()
4563            .find(|evaluation| evaluation.record().id() == "SYNTHETIC-NUMERIC-BETA")
4564            .unwrap();
4565        assert_eq!(
4566            numeric.version_resolution(),
4567            Some(WordPressVersionResolution::Unsupported)
4568        );
4569        assert_eq!(
4570            numeric.version_resolution_reason(),
4571            Some(WordPressVersionResolutionReason::UnsupportedVersionEvidence)
4572        );
4573        assert_eq!(
4574            numeric.version_relation(),
4575            WordPressVersionRelation::Unsupported
4576        );
4577        assert_eq!(
4578            numeric.applicability(),
4579            WordPressApplicability::IndeterminateUnsupported
4580        );
4581
4582        let php = result
4583            .advisories()
4584            .iter()
4585            .find(|evaluation| evaluation.record().id() == "SYNTHETIC-PHP-BETA")
4586            .unwrap();
4587        assert_eq!(
4588            php.version_resolution(),
4589            Some(WordPressVersionResolution::SupportedEquivalent)
4590        );
4591        assert_eq!(
4592            php.version_resolution_reason(),
4593            Some(WordPressVersionResolutionReason::EquivalentSupportedVersions)
4594        );
4595        assert_eq!(
4596            php.version_relation(),
4597            WordPressVersionRelation::WithinDeclaredRange
4598        );
4599        assert_eq!(
4600            php.prerequisites()[0].outcome(),
4601            WordPressPrerequisiteOutcome::MatchedOnSuppliedFacts
4602        );
4603        assert_eq!(
4604            php.applicability(),
4605            WordPressApplicability::CandidateMatchOnDeclaredFacts
4606        );
4607        assert_eq!(
4608            php.exploit_execution(),
4609            WordPressExecutionStatus::NotPerformed
4610        );
4611        assert_eq!(
4612            php.impact_validation(),
4613            WordPressExecutionStatus::NotPerformed
4614        );
4615    }
4616
4617    #[test]
4618    fn profiled_aliases_keep_component_provenance_separate_from_record_resolution() {
4619        let context = PROFILED_CONTEXT.replacen("2.4.0-beta1", "2.4.0+beta1", 1);
4620        let inputs = WordPressReviewInputs::new(
4621            Some(parse_wordpress_context(context.as_bytes()).unwrap()),
4622            Some(parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes()).unwrap()),
4623        );
4624        let signals = [WordPressComponentSignal::generator_metadata(Some("2.4.0-beta1")).unwrap()];
4625        let result = evaluate_wordpress_review(&signals, &inputs).unwrap();
4626        let component = result
4627            .components()
4628            .iter()
4629            .find(|component| component.identity().kind() == WordPressComponentKind::Core)
4630            .unwrap();
4631        assert_eq!(
4632            component.evidence_class(),
4633            WordPressComponentEvidenceClass::ObservedHint
4634        );
4635
4636        let numeric = result
4637            .advisories()
4638            .iter()
4639            .find(|evaluation| evaluation.record().id() == "SYNTHETIC-NUMERIC-BETA")
4640            .unwrap();
4641        assert_eq!(
4642            numeric.version_resolution(),
4643            Some(WordPressVersionResolution::Unsupported)
4644        );
4645        let php = result
4646            .advisories()
4647            .iter()
4648            .find(|evaluation| evaluation.record().id() == "SYNTHETIC-PHP-BETA")
4649            .unwrap();
4650        assert_eq!(
4651            php.version_resolution(),
4652            Some(WordPressVersionResolution::SupportedEquivalent)
4653        );
4654        assert_eq!(
4655            php.version_resolution_reason(),
4656            Some(WordPressVersionResolutionReason::EquivalentSupportedVersions)
4657        );
4658    }
4659
4660    #[test]
4661    fn unsupported_profiled_evidence_is_not_invented_as_a_semantic_conflict() {
4662        let context = PROFILED_CONTEXT.replacen("2.4.0-beta1", "2.4.0-vendor-a", 1);
4663        let inputs = WordPressReviewInputs::new(
4664            Some(parse_wordpress_context(context.as_bytes()).unwrap()),
4665            Some(parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes()).unwrap()),
4666        );
4667        let signals =
4668            [WordPressComponentSignal::generator_metadata(Some("2.4.0-vendor-b")).unwrap()];
4669        let result = evaluate_wordpress_review(&signals, &inputs).unwrap();
4670        for advisory in result.advisories() {
4671            assert_eq!(
4672                advisory.version_resolution(),
4673                Some(WordPressVersionResolution::Unsupported)
4674            );
4675            assert_eq!(
4676                advisory.version_resolution_reason(),
4677                Some(WordPressVersionResolutionReason::UnsupportedVersionEvidence)
4678            );
4679            assert_eq!(
4680                advisory.applicability(),
4681                WordPressApplicability::IndeterminateUnsupported
4682            );
4683        }
4684
4685        let mixed_signals =
4686            [WordPressComponentSignal::generator_metadata(Some("2.4.0-beta1")).unwrap()];
4687        let mixed = evaluate_wordpress_review(&mixed_signals, &inputs).unwrap();
4688        for advisory in mixed.advisories() {
4689            assert_eq!(
4690                advisory.version_resolution(),
4691                Some(WordPressVersionResolution::Unsupported)
4692            );
4693            assert_eq!(
4694                advisory.version_resolution_reason(),
4695                Some(WordPressVersionResolutionReason::UnsupportedVersionEvidence)
4696            );
4697        }
4698    }
4699
4700    #[test]
4701    fn version_evidence_resolution_is_profile_specific() {
4702        let catalog = r#"{
4703          "schema":"security.wordpress-advisory-catalog/v2",
4704          "catalog":{"id":"synthetic-profile-resolution","revision":"r1","retrieved_on":"2026-09-07"},
4705          "records":[
4706            {
4707              "id":"SYNTHETIC-NUMERIC-TRAILING-ZERO",
4708              "component":{"kind":"core","slug":"wordpress"},
4709              "source":{"reference":"https://example.test/advisories/numeric-zero","revision":"r1","retrieved_on":"2026-09-07","usage_basis":"fictional profile-resolution test"},
4710              "comparison_profile":"numeric-dotted/v1",
4711              "summary":"Synthetic numeric trailing-zero record",
4712              "affected_ranges":[{"lower":{"version":"1.0","inclusive":true},"upper":{"version":"1.0.0","inclusive":true}}]
4713            },
4714            {
4715              "id":"SYNTHETIC-PHP-TRAILING-ZERO",
4716              "component":{"kind":"core","slug":"wordpress"},
4717              "source":{"reference":"https://example.test/advisories/php-zero","revision":"r1","retrieved_on":"2026-09-07","usage_basis":"fictional profile-resolution test"},
4718              "comparison_profile":"php-release-subset/v1",
4719              "summary":"Synthetic PHP trailing-zero record",
4720              "affected_ranges":[{"lower":{"version":"1.0","inclusive":true},"upper":{"version":"1.0.0","inclusive":true}}]
4721            }
4722          ]
4723        }"#;
4724        let context = r#"{
4725          "schema":"security.wordpress-context/v1",
4726          "root":"https://example.test/",
4727          "components":[{"kind":"core","slug":"wordpress","version":"1.0.0"}]
4728        }"#;
4729        let inputs = WordPressReviewInputs::new(
4730            Some(parse_wordpress_context(context.as_bytes()).unwrap()),
4731            Some(parse_wordpress_advisory_catalog(catalog.as_bytes()).unwrap()),
4732        );
4733        let signals = [WordPressComponentSignal::generator_metadata(Some("1.0")).unwrap()];
4734        let result = evaluate_wordpress_review(&signals, &inputs).unwrap();
4735        let numeric = result
4736            .advisories()
4737            .iter()
4738            .find(|evaluation| evaluation.record().id().contains("NUMERIC"))
4739            .unwrap();
4740        assert_eq!(
4741            numeric.version_resolution(),
4742            Some(WordPressVersionResolution::SupportedEquivalent)
4743        );
4744        assert_eq!(
4745            numeric.version_resolution_reason(),
4746            Some(WordPressVersionResolutionReason::EquivalentSupportedVersions)
4747        );
4748        assert_eq!(
4749            numeric.version_relation(),
4750            WordPressVersionRelation::WithinDeclaredRange
4751        );
4752
4753        let php = result
4754            .advisories()
4755            .iter()
4756            .find(|evaluation| evaluation.record().id().contains("PHP"))
4757            .unwrap();
4758        assert_eq!(
4759            php.version_resolution(),
4760            Some(WordPressVersionResolution::Conflicting)
4761        );
4762        assert_eq!(
4763            php.version_resolution_reason(),
4764            Some(WordPressVersionResolutionReason::ConflictingVersionEvidence)
4765        );
4766        assert_eq!(php.version_relation(), WordPressVersionRelation::Unknown);
4767        assert_eq!(
4768            php.applicability(),
4769            WordPressApplicability::IndeterminateMissingEvidence
4770        );
4771    }
4772
4773    #[test]
4774    fn profiled_ranges_and_fixed_versions_use_semantic_profile_equality() {
4775        let exclusive_equal_aliases = PROFILED_CATALOG
4776            .replacen("2.4.0-beta0", "1.0-alpha1", 1)
4777            .replacen(
4778                "2.4.0\",\"inclusive\":false",
4779                "1.0a1\",\"inclusive\":false",
4780                1,
4781            );
4782        assert_eq!(
4783            parse_wordpress_advisory_catalog(exclusive_equal_aliases.as_bytes()),
4784            Err(WordPressReviewError::InvalidVersionRange)
4785        );
4786
4787        let duplicate_fixed_aliases = PROFILED_CATALOG.replacen(
4788            "\"fixed_versions\":[\"2.4.0\"]",
4789            "\"fixed_versions\":[\"2.4.0-alpha1\",\"2.4.0a1\"]",
4790            1,
4791        );
4792        assert_eq!(
4793            parse_wordpress_advisory_catalog(duplicate_fixed_aliases.as_bytes()),
4794            Err(WordPressReviewError::InvalidCatalog)
4795        );
4796
4797        let php_record = parse_wordpress_advisory_catalog(PROFILED_CATALOG.as_bytes())
4798            .unwrap()
4799            .records()
4800            .iter()
4801            .find(|record| record.id() == "SYNTHETIC-PHP-BETA")
4802            .unwrap()
4803            .clone();
4804        assert_eq!(
4805            php_record.compare_versions("1.0+beta1", "1.0-beta1"),
4806            Some(Ordering::Equal)
4807        );
4808        assert_eq!(
4809            php_record.compare_versions("1.0", "1.0.0"),
4810            Some(Ordering::Less)
4811        );
4812        assert_eq!(php_record.compare_versions("1.0-vendor1", "1.0"), None);
4813
4814        let mut interleaved_aliases: Value = serde_json::from_str(PROFILED_CATALOG).unwrap();
4815        interleaved_aliases["records"][1]["affected_ranges"] = serde_json::json!([
4816            {
4817                "lower":{"version":"1.0-alpha1","inclusive":true},
4818                "upper":{"version":"3.0","inclusive":false}
4819            },
4820            {
4821                "lower":{"version":"1.0-alpha1","inclusive":true},
4822                "upper":{"version":"4.0","inclusive":false}
4823            },
4824            {
4825                "lower":{"version":"1.0a1","inclusive":true},
4826                "upper":{"version":"3.0","inclusive":false}
4827            }
4828        ]);
4829        let bytes = serde_json::to_vec(&interleaved_aliases).unwrap();
4830        let catalogue = parse_wordpress_advisory_catalog(&bytes).unwrap();
4831        let ranges = catalogue
4832            .records()
4833            .iter()
4834            .find(|record| record.id() == "SYNTHETIC-PHP-BETA")
4835            .unwrap()
4836            .profiled_affected_ranges();
4837        assert_eq!(ranges.len(), 2);
4838        assert_eq!(ranges[0].lower().unwrap().declared(), "1.0-alpha1");
4839        assert_eq!(ranges[0].upper().unwrap().declared(), "3.0");
4840    }
4841
4842    #[test]
4843    fn catalog_rejects_duplicates_unknown_fields_and_malformed_ranges() {
4844        let duplicate = CATALOG.replacen(
4845            "\"summary\":\"Synthetic bounded core record\"",
4846            "\"summary\":\"Synthetic bounded core record\",\"summary\":\"other\"",
4847            1,
4848        );
4849        assert_eq!(
4850            parse_wordpress_advisory_catalog(duplicate.as_bytes()),
4851            Err(WordPressReviewError::DuplicateKey)
4852        );
4853        let unknown = CATALOG.replacen(
4854            "\"summary\":\"Synthetic bounded core record\"",
4855            "\"summary\":\"Synthetic bounded core record\",\"predicate\":\"execute-me\"",
4856            1,
4857        );
4858        assert_eq!(
4859            parse_wordpress_advisory_catalog(unknown.as_bytes()),
4860            Err(WordPressReviewError::InvalidCatalog)
4861        );
4862        let backwards = CATALOG.replacen("6.9.0", "6.9.9", 1);
4863        assert_eq!(
4864            parse_wordpress_advisory_catalog(backwards.as_bytes()),
4865            Err(WordPressReviewError::InvalidVersionRange)
4866        );
4867        let inferred_range = CATALOG.replacen(
4868            "{\"lower\":{\"version\":\"6.9.0\",\"inclusive\":true},\"upper\":{\"version\":\"6.9.5\",\"inclusive\":false}}",
4869            "{}",
4870            1,
4871        );
4872        assert_eq!(
4873            parse_wordpress_advisory_catalog(inferred_range.as_bytes()),
4874            Err(WordPressReviewError::InvalidVersionRange)
4875        );
4876    }
4877
4878    #[test]
4879    fn numeric_dotted_profile_has_explicit_trailing_zero_and_boundary_semantics() {
4880        let one_two = NumericDottedVersion::parse("1.2").unwrap();
4881        let one_two_zero = NumericDottedVersion::parse("1.2.0").unwrap();
4882        assert_eq!(one_two, one_two_zero);
4883        assert!(NumericDottedVersion::parse("1.2.1").unwrap() > one_two);
4884        for unsupported in ["1.2-beta", "v1.2", "1..2", "01.2", "4294967296"] {
4885            assert_eq!(
4886                NumericDottedVersion::parse(unsupported),
4887                Err(NumericDottedVersionError)
4888            );
4889        }
4890
4891        let catalog = parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap();
4892        let range = &catalog.records()[0].affected_ranges()[0];
4893        assert!(range.contains(&NumericDottedVersion::parse("6.9.0").unwrap()));
4894        assert!(range.contains(&NumericDottedVersion::parse("6.9.4").unwrap()));
4895        assert!(!range.contains(&NumericDottedVersion::parse("6.9.5").unwrap()));
4896        assert!(!range.contains(&NumericDottedVersion::parse("7.0.1").unwrap()));
4897    }
4898
4899    #[test]
4900    fn explicit_endpoint_inclusivity_and_missing_ranges_remain_distinct() {
4901        let inclusive_upper = CATALOG.replacen(
4902            "\"version\":\"6.9.5\",\"inclusive\":false",
4903            "\"version\":\"6.9.5\",\"inclusive\":true",
4904            1,
4905        );
4906        let catalog = parse_wordpress_advisory_catalog(inclusive_upper.as_bytes()).unwrap();
4907        assert!(catalog.records()[0].affected_ranges()[0]
4908            .contains(&NumericDottedVersion::parse("6.9.5").unwrap()));
4909
4910        let no_ranges = CATALOG.replacen(
4911            r#""affected_ranges":[
4912            {"lower":{"version":"6.9.0","inclusive":true},"upper":{"version":"6.9.5","inclusive":false}},
4913            {"lower":{"version":"7.0.0","inclusive":true},"upper":{"version":"7.0.2","inclusive":false}}
4914          ]"#,
4915            r#""affected_ranges":[]"#,
4916            1,
4917        );
4918        let inputs = WordPressReviewInputs::new(
4919            Some(parse_wordpress_context(CONTEXT.as_bytes()).unwrap()),
4920            Some(parse_wordpress_advisory_catalog(no_ranges.as_bytes()).unwrap()),
4921        );
4922        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
4923        assert_eq!(
4924            result.advisories()[0].version_relation(),
4925            WordPressVersionRelation::Unknown,
4926            "a documented fixed version must not invent an affected interval"
4927        );
4928    }
4929
4930    #[test]
4931    fn evaluator_separates_observation_operator_source_and_candidate_applicability() {
4932        let signals = [WordPressComponentSignal::generator_metadata(Some("6.9.4")).unwrap()];
4933        let result = evaluate_wordpress_review(&signals, &inputs()).unwrap();
4934        assert_eq!(result.catalog_status(), WordPressCatalogStatus::Evaluated);
4935        assert_eq!(result.catalog_metadata().unwrap().revision(), "r1");
4936        let core = result
4937            .components()
4938            .iter()
4939            .find(|component| component.identity().kind() == WordPressComponentKind::Core)
4940            .unwrap();
4941        assert_eq!(
4942            core.evidence_class(),
4943            WordPressComponentEvidenceClass::ObservedHint
4944        );
4945        assert_eq!(
4946            core.identity_sources(),
4947            [
4948                WordPressEvidenceSource::GeneratorMetadata,
4949                WordPressEvidenceSource::OperatorContext,
4950            ]
4951        );
4952        assert_eq!(
4953            core.confidence_classes(),
4954            [
4955                WordPressEvidenceConfidence::PublicDeclaration,
4956                WordPressEvidenceConfidence::OperatorAssertion,
4957            ]
4958        );
4959        assert_eq!(
4960            core.versions()[0].confidence(),
4961            WordPressEvidenceConfidence::PublicDeclaration
4962        );
4963        let core_advisory = &result.advisories()[0];
4964        assert_eq!(
4965            core_advisory.version_relation(),
4966            WordPressVersionRelation::WithinDeclaredRange
4967        );
4968        assert_eq!(
4969            core_advisory.applicability(),
4970            WordPressApplicability::CandidateMatchOnDeclaredFacts
4971        );
4972        assert_eq!(
4973            core_advisory.exploit_execution(),
4974            WordPressExecutionStatus::NotPerformed
4975        );
4976        assert_eq!(
4977            core_advisory.impact_validation(),
4978            WordPressExecutionStatus::NotPerformed
4979        );
4980    }
4981
4982    #[test]
4983    fn repeated_hints_do_not_become_independent_evidence() {
4984        let signal = WordPressComponentSignal::same_origin_asset(
4985            WordPressComponentKind::Plugin,
4986            "sample-plugin",
4987        )
4988        .unwrap();
4989        let result = evaluate_wordpress_review(
4990            &[signal.clone(), signal.clone(), signal],
4991            &WordPressReviewInputs::default(),
4992        )
4993        .unwrap();
4994        assert_eq!(result.components().len(), 1);
4995        assert_eq!(
4996            result.components()[0].identity_sources(),
4997            [WordPressEvidenceSource::SameOriginAssetPath]
4998        );
4999        assert!(result.components()[0].versions().is_empty());
5000
5001        let core = WordPressComponentSignal::core_asset();
5002        assert_eq!(core.identity(), &WordPressComponentIdentity::core());
5003        assert_eq!(core.source(), WordPressEvidenceSource::SameOriginAssetPath);
5004        assert_eq!(
5005            core.confidence(),
5006            WordPressEvidenceConfidence::StructuralHint
5007        );
5008    }
5009
5010    #[test]
5011    fn contradictory_versions_are_not_resolved_by_last_write() {
5012        let signals = [WordPressComponentSignal::generator_metadata(Some("6.9.3")).unwrap()];
5013        let result = evaluate_wordpress_review(&signals, &inputs()).unwrap();
5014        assert_eq!(
5015            result.components()[0].evidence_class(),
5016            WordPressComponentEvidenceClass::Conflicting
5017        );
5018        assert_eq!(
5019            result.advisories()[0].version_relation(),
5020            WordPressVersionRelation::Unknown
5021        );
5022        assert_eq!(
5023            result.advisories()[0].applicability(),
5024            WordPressApplicability::IndeterminateMissingEvidence
5025        );
5026    }
5027
5028    #[test]
5029    fn unsupported_observed_version_stays_indeterminate() {
5030        let context = CONTEXT.replace("6.9.4", "6.9.4-RC1");
5031        let inputs = WordPressReviewInputs::new(
5032            Some(parse_wordpress_context(context.as_bytes()).unwrap()),
5033            Some(parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()),
5034        );
5035        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5036        assert_eq!(
5037            result.advisories()[0].version_relation(),
5038            WordPressVersionRelation::Unsupported
5039        );
5040        assert_eq!(
5041            result.advisories()[0].applicability(),
5042            WordPressApplicability::IndeterminateUnsupported
5043        );
5044    }
5045
5046    #[test]
5047    fn prerequisite_outcomes_do_not_turn_conflicts_into_security_claims() {
5048        let result = evaluate_wordpress_review(&[], &inputs()).unwrap();
5049        let plugin = result
5050            .advisories()
5051            .iter()
5052            .find(|evaluation| evaluation.record().id() == "SYNTHETIC-PLUGIN-1")
5053            .unwrap();
5054        assert_eq!(
5055            plugin.prerequisites()[0].outcome(),
5056            WordPressPrerequisiteOutcome::MatchedOnSuppliedFacts
5057        );
5058        assert_eq!(
5059            plugin.prerequisites()[1].outcome(),
5060            WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts
5061        );
5062        assert_eq!(
5063            plugin.applicability(),
5064            WordPressApplicability::ContradictedByDeclaredFacts
5065        );
5066    }
5067
5068    #[test]
5069    fn a_contradicted_requirement_dominates_an_unresolved_unsupported_requirement() {
5070        let catalogue = CATALOG.replacen(
5071            r#"{"kind":"multisite","equals":"enabled"}"#,
5072            r#"{"kind":"multisite","equals":"disabled"},{"kind":"unsupported","name":"vendor_specific_runtime_state"}"#,
5073            1,
5074        );
5075        let inputs = WordPressReviewInputs::new(
5076            Some(parse_wordpress_context(CONTEXT.as_bytes()).unwrap()),
5077            Some(parse_wordpress_advisory_catalog(catalogue.as_bytes()).unwrap()),
5078        );
5079        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5080        let core = result
5081            .advisories()
5082            .iter()
5083            .find(|evaluation| evaluation.record().id() == "SYNTHETIC-CORE-1")
5084            .unwrap();
5085
5086        assert!(core.prerequisites().iter().any(|evaluation| {
5087            evaluation.outcome() == WordPressPrerequisiteOutcome::ContradictedOnSuppliedFacts
5088        }));
5089        assert!(core.prerequisites().iter().any(|evaluation| {
5090            evaluation.outcome() == WordPressPrerequisiteOutcome::Unsupported
5091        }));
5092        assert_eq!(
5093            core.applicability(),
5094            WordPressApplicability::ContradictedByDeclaredFacts
5095        );
5096    }
5097
5098    #[test]
5099    fn absent_or_unknown_patch_assertions_do_not_mean_patch_absence() {
5100        let without_patch = CONTEXT.replace(
5101            r#""patches":[{"id":"vendor-backport-1","state":"applied"}]"#,
5102            r#""patches":[]"#,
5103        );
5104        let inputs = WordPressReviewInputs::new(
5105            Some(parse_wordpress_context(without_patch.as_bytes()).unwrap()),
5106            Some(parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()),
5107        );
5108        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5109        let plugin = result
5110            .advisories()
5111            .iter()
5112            .find(|evaluation| evaluation.record().id() == "SYNTHETIC-PLUGIN-1")
5113            .unwrap();
5114        assert_eq!(
5115            plugin.prerequisites()[1].outcome(),
5116            WordPressPrerequisiteOutcome::Unknown
5117        );
5118        assert_eq!(
5119            plugin.applicability(),
5120            WordPressApplicability::IndeterminateMissingEvidence
5121        );
5122    }
5123
5124    #[test]
5125    fn unsupported_and_missing_prerequisites_are_distinct() {
5126        let unsupported = CATALOG.replacen(
5127            "{\"kind\":\"hosting_os\",\"equals\":\"linux\"}",
5128            "{\"kind\":\"unsupported\",\"name\":\"php-extension-state\"}",
5129            1,
5130        );
5131        let inputs = WordPressReviewInputs::new(
5132            None,
5133            Some(parse_wordpress_advisory_catalog(unsupported.as_bytes()).unwrap()),
5134        );
5135        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5136        let unsupported = result.advisories()[0]
5137            .prerequisites()
5138            .iter()
5139            .find(|evaluation| {
5140                matches!(
5141                    evaluation.prerequisite(),
5142                    WordPressPrerequisite::Unsupported { .. }
5143                )
5144            })
5145            .unwrap();
5146        assert_eq!(
5147            unsupported.outcome(),
5148            WordPressPrerequisiteOutcome::Unsupported
5149        );
5150        let multisite = result.advisories()[0]
5151            .prerequisites()
5152            .iter()
5153            .find(|evaluation| {
5154                matches!(
5155                    evaluation.prerequisite(),
5156                    WordPressPrerequisite::Multisite { .. }
5157                )
5158            })
5159            .unwrap();
5160        assert_eq!(multisite.outcome(), WordPressPrerequisiteOutcome::Unknown);
5161        assert_eq!(
5162            result.advisories()[0].applicability(),
5163            WordPressApplicability::IndeterminateUnsupported
5164        );
5165    }
5166
5167    #[test]
5168    fn absent_catalogue_is_visible_and_never_an_all_clear() {
5169        let result = evaluate_wordpress_review(
5170            &[WordPressComponentSignal::generator_metadata(None).unwrap()],
5171            &WordPressReviewInputs::default(),
5172        )
5173        .unwrap();
5174        assert_eq!(
5175            result.catalog_status(),
5176            WordPressCatalogStatus::CatalogNotSupplied
5177        );
5178        assert_eq!(result.catalog_metadata(), None);
5179        assert!(result.advisories().is_empty());
5180        assert_eq!(
5181            result.components()[0].evidence_class(),
5182            WordPressComponentEvidenceClass::ObservedHint
5183        );
5184    }
5185
5186    #[test]
5187    fn no_matching_component_keeps_record_indeterminate() {
5188        let inputs = WordPressReviewInputs::new(
5189            None,
5190            Some(parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()),
5191        );
5192        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5193        for advisory in result.advisories() {
5194            assert_eq!(
5195                advisory.component_evidence(),
5196                WordPressComponentEvidenceClass::Unknown
5197            );
5198            assert_eq!(
5199                advisory.version_relation(),
5200                WordPressVersionRelation::Unknown
5201            );
5202            assert!(matches!(
5203                advisory.applicability(),
5204                WordPressApplicability::IndeterminateMissingEvidence
5205                    | WordPressApplicability::IndeterminateUnsupported
5206            ));
5207        }
5208    }
5209
5210    #[test]
5211    fn parser_is_order_independent_but_catalog_output_is_deterministic() {
5212        let reordered = CATALOG.replace(
5213            "\"schema\":\"security.wordpress-advisory-catalog/v1\",\n      \"catalog\"",
5214            "\"catalog\"",
5215        );
5216        let reordered = reordered.replacen(
5217            "\"records\":[",
5218            "\"schema\":\"security.wordpress-advisory-catalog/v1\",\"records\":[",
5219            1,
5220        );
5221        let parsed = parse_wordpress_advisory_catalog(reordered.as_bytes()).unwrap();
5222        assert_eq!(
5223            parsed,
5224            parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap()
5225        );
5226        assert_eq!(parsed.records()[0].id(), "SYNTHETIC-CORE-1");
5227        assert_eq!(parsed.records()[1].id(), "SYNTHETIC-PLUGIN-1");
5228    }
5229
5230    #[test]
5231    fn schema_and_catalog_size_limits_fail_closed() {
5232        let wrong = CONTEXT.replace(WORDPRESS_CONTEXT_SCHEMA, "security.wordpress-context/v2");
5233        assert_eq!(
5234            parse_wordpress_context(wrong.as_bytes()),
5235            Err(WordPressReviewError::UnsupportedSchema)
5236        );
5237        assert_eq!(
5238            parse_wordpress_advisory_catalog(&vec![b' '; MAX_WORDPRESS_ADVISORY_CATALOG_BYTES + 1]),
5239            Err(WordPressReviewError::CatalogTooLarge)
5240        );
5241    }
5242
5243    #[test]
5244    fn source_urls_and_inert_data_cannot_express_executable_policy() {
5245        for replacement in [
5246            "javascript:alert(1)",
5247            "http://example.test/advisory",
5248            "https://user:secret@example.test/advisory",
5249        ] {
5250            let document = CATALOG.replace("https://example.test/advisories/core-1", replacement);
5251            assert_eq!(
5252                parse_wordpress_advisory_catalog(document.as_bytes()),
5253                Err(WordPressReviewError::InvalidCatalog)
5254            );
5255        }
5256        let script = CATALOG.replacen(
5257            "\"summary\":\"Synthetic bounded core record\"",
5258            "\"summary\":\"Synthetic bounded core record\",\"script\":\"system('id')\"",
5259            1,
5260        );
5261        assert_eq!(
5262            parse_wordpress_advisory_catalog(script.as_bytes()),
5263            Err(WordPressReviewError::InvalidCatalog)
5264        );
5265    }
5266
5267    #[test]
5268    fn signal_limit_and_invalid_asset_identities_fail_closed() {
5269        let signal = WordPressComponentSignal::generator_metadata(None).unwrap();
5270        assert_eq!(
5271            evaluate_wordpress_review(
5272                &vec![signal; MAX_WORDPRESS_SIGNALS + 1],
5273                &WordPressReviewInputs::default()
5274            ),
5275            Err(WordPressReviewError::SignalLimitExceeded)
5276        );
5277        assert!(WordPressComponentSignal::same_origin_asset(
5278            WordPressComponentKind::Plugin,
5279            "../escape"
5280        )
5281        .is_err());
5282        assert!(WordPressComponentSignal::same_origin_asset(
5283            WordPressComponentKind::Plugin,
5284            "wordpress"
5285        )
5286        .is_err());
5287    }
5288
5289    #[test]
5290    fn disjoint_signal_and_context_component_bounds_form_one_valid_result() {
5291        let signals = (0..MAX_WORDPRESS_SIGNALS)
5292            .map(|index| {
5293                WordPressComponentSignal::same_origin_asset(
5294                    WordPressComponentKind::Plugin,
5295                    &format!("observed-{index:03}"),
5296                )
5297                .unwrap()
5298            })
5299            .collect::<Vec<_>>();
5300        let components = (0..MAX_WORDPRESS_CONTEXT_COMPONENTS)
5301            .map(|index| WordPressContextComponent {
5302                identity: WordPressComponentIdentity::new(
5303                    WordPressComponentKind::Plugin,
5304                    format!("declared-{index:03}"),
5305                )
5306                .unwrap(),
5307                version: None,
5308                activation: None,
5309                patches: Vec::new(),
5310                inventory_status: None,
5311            })
5312            .collect();
5313        let context = WordPressContext {
5314            root_url: Url::parse("https://example.test/").unwrap(),
5315            hosting_os: None,
5316            multisite: None,
5317            components,
5318        };
5319
5320        let result =
5321            evaluate_wordpress_review(&signals, &WordPressReviewInputs::new(Some(context), None))
5322                .unwrap();
5323
5324        assert_eq!(
5325            MAX_WORDPRESS_RESULT_COMPONENTS,
5326            MAX_WORDPRESS_SIGNALS + MAX_WORDPRESS_CONTEXT_COMPONENTS
5327        );
5328        assert_eq!(result.components().len(), MAX_WORDPRESS_RESULT_COMPONENTS);
5329    }
5330
5331    #[test]
5332    fn combined_signal_and_context_versions_use_the_explicit_result_bound() {
5333        let signals = (0..MAX_WORDPRESS_SIGNALS)
5334            .map(|index| {
5335                WordPressComponentSignal::generator_metadata(Some(&format!("1.0.{index}"))).unwrap()
5336            })
5337            .collect::<Vec<_>>();
5338        let components = (0..MAX_WORDPRESS_CONTEXT_COMPONENTS)
5339            .map(|index| WordPressContextComponent {
5340                identity: WordPressComponentIdentity::new(
5341                    WordPressComponentKind::Plugin,
5342                    format!("declared-{index:03}"),
5343                )
5344                .unwrap(),
5345                version: Some(format!("2.0.{index}")),
5346                activation: None,
5347                patches: Vec::new(),
5348                inventory_status: None,
5349            })
5350            .collect();
5351        let context = WordPressContext {
5352            root_url: Url::parse("https://example.test/").unwrap(),
5353            hosting_os: None,
5354            multisite: None,
5355            components,
5356        };
5357
5358        let result =
5359            evaluate_wordpress_review(&signals, &WordPressReviewInputs::new(Some(context), None))
5360                .unwrap();
5361        let version_rows = result
5362            .components()
5363            .iter()
5364            .map(|component| component.versions().len())
5365            .sum::<usize>();
5366
5367        assert_eq!(
5368            MAX_WORDPRESS_RESULT_VERSION_EVIDENCE,
5369            MAX_WORDPRESS_SIGNALS + MAX_WORDPRESS_CONTEXT_COMPONENTS
5370        );
5371        assert_eq!(version_rows, MAX_WORDPRESS_RESULT_VERSION_EVIDENCE);
5372    }
5373
5374    #[test]
5375    fn external_catalog_selects_only_exact_component_associations() {
5376        let import = parse_wordfence_v3_production(
5377            &include_bytes!(
5378                "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5379            )[..],
5380        )
5381        .unwrap();
5382        let context = parse_wordpress_context(
5383            br#"{
5384              "schema":"security.wordpress-context/v1",
5385              "root":"https://example.test/",
5386              "components":[
5387                {"kind":"plugin","slug":"termivar-fixture-component","version":"1.0"}
5388              ]
5389            }"#,
5390        )
5391        .unwrap();
5392        let inputs = WordPressReviewInputs::new(Some(context), None)
5393            .with_wordfence_v3_catalog(import)
5394            .unwrap();
5395
5396        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5397        let external = result.external_review().unwrap();
5398
5399        assert_eq!(result.catalog_status(), WordPressCatalogStatus::Evaluated);
5400        assert_eq!(result.catalog_schema(), None);
5401        assert_eq!(result.catalog_metadata(), None);
5402        assert!(result.advisories().is_empty());
5403        assert_eq!(external.counts().parsed_records(), 2);
5404        assert_eq!(external.counts().software_associations(), 3);
5405        assert_eq!(external.counts().exact_identity_associations(), 3);
5406        assert_eq!(external.counts().candidate_identity_associations(), 0);
5407        assert_eq!(external.counts().ambiguous_identity_associations(), 0);
5408        assert_eq!(external.counts().unresolved_identity_associations(), 0);
5409        assert_eq!(external.counts().selected_associations(), 1);
5410        assert_eq!(external.counts().evaluable_associations(), 0);
5411        assert_eq!(external.counts().unsupported_associations(), 1);
5412        assert_eq!(external.counts().excluded_associations(), 2);
5413        assert_eq!(external.evaluations().len(), 1);
5414        assert_eq!(external.notices().len(), 1);
5415        assert_eq!(external.mapping_revision(), WORDFENCE_V3_MAPPING_REVISION);
5416        assert_eq!(external.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V1);
5417        assert!(!external.requires_audit_v6());
5418        let evaluation = &external.evaluations()[0];
5419        assert_eq!(
5420            evaluation.key().source_namespace(),
5421            WORDFENCE_V3_SOURCE_NAMESPACE
5422        );
5423        assert_eq!(
5424            evaluation.key().component().kind(),
5425            WordPressComponentKind::Plugin
5426        );
5427        assert_eq!(
5428            evaluation.key().component().slug(),
5429            "termivar-fixture-component"
5430        );
5431        assert_eq!(
5432            evaluation.key().source_component().slug(),
5433            "termivar-fixture-component"
5434        );
5435        assert_eq!(
5436            evaluation.identity_mapping(),
5437            WordfenceV3IdentityMapping::Exact
5438        );
5439        assert_eq!(
5440            evaluation.version_relation(),
5441            WordPressExternalVersionRelation::SourceComparisonSemanticsUnresolved
5442        );
5443        assert_eq!(
5444            evaluation.applicability(),
5445            WordPressExternalApplicability::IndeterminateUnsupported
5446        );
5447        assert_eq!(
5448            evaluation.version_evidence_resolution().status(),
5449            WordPressExternalVersionEvidenceStatus::SingleDeclaration
5450        );
5451        assert_eq!(
5452            evaluation
5453                .version_evidence_resolution()
5454                .evidence_row_count(),
5455            1
5456        );
5457        assert_eq!(
5458            evaluation
5459                .version_evidence_resolution()
5460                .distinct_spelling_count(),
5461            1
5462        );
5463        assert_eq!(
5464            evaluation.exploit_execution(),
5465            WordPressExecutionStatus::NotPerformed
5466        );
5467        assert_eq!(
5468            evaluation.impact_validation(),
5469            WordPressExecutionStatus::NotPerformed
5470        );
5471    }
5472
5473    #[test]
5474    fn external_version_evidence_resolution_is_evaluator_owned_and_comparator_neutral() {
5475        for (operator_version, expected_status, expected_distinct) in [
5476            (
5477                "1.0",
5478                WordPressExternalVersionEvidenceStatus::RepeatedExactDeclaration,
5479                1,
5480            ),
5481            (
5482                "1.0.0",
5483                WordPressExternalVersionEvidenceStatus::MultipleDistinctDeclarations,
5484                2,
5485            ),
5486        ] {
5487            let import = parse_wordfence_v3_production(
5488                &include_bytes!(
5489                    "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5490                )[..],
5491            )
5492            .unwrap();
5493            let context = parse_wordpress_context(
5494                format!(
5495                    r#"{{
5496                      "schema":"security.wordpress-context/v1",
5497                      "root":"https://example.test/",
5498                      "components":[{{"kind":"core","slug":"wordpress","version":"{operator_version}"}}]
5499                    }}"#
5500                )
5501                .as_bytes(),
5502            )
5503            .unwrap();
5504            let signals = [WordPressComponentSignal::generator_metadata(Some("1.0")).unwrap()];
5505            let result = evaluate_wordpress_review(
5506                &signals,
5507                &WordPressReviewInputs::new(Some(context), None)
5508                    .with_wordfence_v3_catalog(import)
5509                    .unwrap(),
5510            )
5511            .unwrap();
5512            let core = result
5513                .external_review()
5514                .unwrap()
5515                .evaluations()
5516                .iter()
5517                .find(|evaluation| {
5518                    evaluation.key().component().kind() == WordPressComponentKind::Core
5519                })
5520                .unwrap();
5521            let resolution = core.version_evidence_resolution();
5522            assert_eq!(resolution.status(), expected_status);
5523            assert_eq!(resolution.evidence_row_count(), 2);
5524            assert_eq!(resolution.distinct_spelling_count(), expected_distinct);
5525            assert_eq!(
5526                core.version_relation(),
5527                WordPressExternalVersionRelation::SourceComparisonSemanticsUnresolved
5528            );
5529        }
5530    }
5531
5532    #[test]
5533    fn external_catalog_no_match_retains_counts_without_unrelated_rows_or_rights() {
5534        let import = parse_wordfence_v3_production(
5535            &include_bytes!(
5536                "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5537            )[..],
5538        )
5539        .unwrap();
5540        let result = evaluate_wordpress_review(
5541            &[],
5542            &WordPressReviewInputs::new(None, None)
5543                .with_wordfence_v3_catalog(import)
5544                .unwrap(),
5545        )
5546        .unwrap();
5547        let external = result.external_review().unwrap();
5548        assert!(result.components().is_empty());
5549        assert_eq!(external.counts().selected_associations(), 0);
5550        assert_eq!(external.counts().excluded_associations(), 3);
5551        assert!(external.evaluations().is_empty());
5552        assert!(external.notices().is_empty());
5553    }
5554
5555    #[test]
5556    fn every_selected_external_row_stays_indeterminate_without_a_comparison_policy() {
5557        let import = parse_wordfence_v3_production(
5558            &include_bytes!(
5559                "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5560            )[..],
5561        )
5562        .unwrap();
5563        let context = parse_wordpress_context(
5564            br#"{
5565              "schema":"security.wordpress-context/v1",
5566              "root":"https://example.test/",
5567              "components":[
5568                {"kind":"core","slug":"wordpress","version":"1.0"},
5569                {"kind":"plugin","slug":"termivar-fixture-component","version":"1.0.0"},
5570                {"kind":"theme","slug":"termivar-fixture-component","version":"2.0"}
5571              ]
5572            }"#,
5573        )
5574        .unwrap();
5575        let result = evaluate_wordpress_review(
5576            &[],
5577            &WordPressReviewInputs::new(Some(context), None)
5578                .with_wordfence_v3_catalog(import)
5579                .unwrap(),
5580        )
5581        .unwrap();
5582        let external = result.external_review().unwrap();
5583        assert_eq!(external.counts().selected_associations(), 3);
5584        assert_eq!(external.counts().evaluable_associations(), 0);
5585        assert_eq!(external.counts().unsupported_associations(), 3);
5586        assert!(external.evaluations().iter().all(|evaluation| {
5587            evaluation.version_relation()
5588                == WordPressExternalVersionRelation::SourceComparisonSemanticsUnresolved
5589                && evaluation.applicability()
5590                    == WordPressExternalApplicability::IndeterminateUnsupported
5591                && evaluation.exploit_execution() == WordPressExecutionStatus::NotPerformed
5592                && evaluation.impact_validation() == WordPressExecutionStatus::NotPerformed
5593        }));
5594    }
5595
5596    fn external_review_for(
5597        context: &str,
5598        document: &str,
5599        profile: WordPressComparisonProfile,
5600    ) -> WordPressReviewResult {
5601        let context = parse_wordpress_context(context.as_bytes()).unwrap();
5602        let import = parse_wordfence_v3_production(document.as_bytes()).unwrap();
5603        let inputs = WordPressReviewInputs::new(Some(context), None)
5604            .with_wordfence_v3_catalog(import)
5605            .unwrap()
5606            .with_external_version_profile(profile)
5607            .unwrap();
5608        evaluate_wordpress_review(&[], &inputs).unwrap()
5609    }
5610
5611    fn single_external_association(
5612        component_type: &str,
5613        slug: &str,
5614        affected_versions: &str,
5615    ) -> String {
5616        format!(
5617            r#"{{
5618              "00000000-0000-4000-8000-000000000099": {{
5619                "id":"00000000-0000-4000-8000-000000000099",
5620                "title":"[SYNTHETIC] External boundary fixture",
5621                "software":[{{
5622                  "type":"{component_type}",
5623                  "name":"[SYNTHETIC] Boundary component",
5624                  "slug":"{slug}",
5625                  "affected_versions":{affected_versions},
5626                  "patched":false,
5627                  "patched_versions":[],
5628                  "remediation":"Review source guidance"
5629                }}],
5630                "informational":false,
5631                "description":"Synthetic boundary fixture",
5632                "references":["https://example.invalid/termivar/external-boundary"],
5633                "cwe":null,
5634                "cvss":null,
5635                "cve":null,
5636                "cve_link":null,
5637                "researchers":[],
5638                "published":null,
5639                "updated":null,
5640                "copyrights":null
5641              }}
5642            }}"#
5643        )
5644    }
5645
5646    #[test]
5647    fn unresolved_source_identity_detail_is_bounded_without_losing_total_accounting() {
5648        let total = MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS + 1;
5649        let mut records = serde_json::Map::new();
5650        for index in 0..total {
5651            let id = format!("00000000-0000-4000-8000-{index:012x}");
5652            records.insert(
5653                id.clone(),
5654                serde_json::json!({
5655                    "id": id,
5656                    "title": "[SYNTHETIC] Unresolved identity projection fixture",
5657                    "software": [{
5658                        "type": "plugin",
5659                        "name": "[SYNTHETIC] Unresolved component",
5660                        "slug": format!("vendor/plugin-{index}.php"),
5661                        "affected_versions": {
5662                            "[1.0, 2.0]": {
5663                                "from_version": "1.0",
5664                                "from_inclusive": true,
5665                                "to_version": "2.0",
5666                                "to_inclusive": true
5667                            }
5668                        },
5669                        "patched": false,
5670                        "patched_versions": [],
5671                        "remediation": "Synthetic fixture guidance"
5672                    }],
5673                    "informational": false,
5674                    "description": "Synthetic bounded projection fixture",
5675                    "references": ["https://example.invalid/termivar/unresolved"],
5676                    "cwe": null,
5677                    "cvss": null,
5678                    "cve": null,
5679                    "cve_link": null,
5680                    "researchers": [],
5681                    "published": null,
5682                    "updated": null,
5683                    "copyrights": null
5684                }),
5685            );
5686        }
5687        let document = serde_json::to_vec(&serde_json::Value::Object(records)).unwrap();
5688        let import = parse_wordfence_v3_production(document.as_slice()).unwrap();
5689        let inputs = WordPressReviewInputs::new(None, None)
5690            .with_wordfence_v3_catalog(import)
5691            .unwrap();
5692        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5693        let external = result.external_review().unwrap();
5694
5695        assert_eq!(external.counts().software_associations(), total);
5696        assert_eq!(external.counts().unresolved_identity_associations(), total);
5697        assert_eq!(
5698            external.counts().projected_identity_limitations(),
5699            MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS
5700        );
5701        assert_eq!(external.counts().unprojected_identity_limitations(), 1);
5702        assert_eq!(
5703            external.identity_limitations().len(),
5704            MAX_WORDPRESS_EXTERNAL_IDENTITY_LIMITATION_PROJECTIONS
5705        );
5706        assert_eq!(external.counts().selected_associations(), 0);
5707        assert_eq!(external.counts().mapped_unselected_associations(), 0);
5708        assert_eq!(external.counts().excluded_associations(), total);
5709    }
5710
5711    #[test]
5712    fn large_unresolved_source_rows_stop_at_detail_byte_budget_instead_of_failing_review() {
5713        const TOTAL: usize = 8;
5714        let mut records = serde_json::Map::new();
5715        for index in 0..TOTAL {
5716            let id = format!("00000000-0000-4000-8000-{index:012x}");
5717            let references = (0..64)
5718                .map(|reference| {
5719                    format!(
5720                        "https://example.invalid/{}/{index}/{reference}",
5721                        "x".repeat(1_900)
5722                    )
5723                })
5724                .collect::<Vec<_>>();
5725            let researchers = (0..64)
5726                .map(|researcher| format!("{}-{researcher}", "r".repeat(480)))
5727                .collect::<Vec<_>>();
5728            let mut affected_versions = serde_json::Map::new();
5729            for range in 0..128 {
5730                affected_versions.insert(
5731                    format!("[{range}.0.0,{range}.0.1]"),
5732                    serde_json::json!({
5733                        "from_version": format!("{range}.0.0"),
5734                        "from_inclusive": true,
5735                        "to_version": format!("{range}.0.1"),
5736                        "to_inclusive": true
5737                    }),
5738                );
5739            }
5740            let mut copyrights = serde_json::Map::new();
5741            copyrights.insert("message".to_owned(), serde_json::json!("m".repeat(2_048)));
5742            for party in 0..15 {
5743                copyrights.insert(
5744                    format!("fixture-{index}-{party}"),
5745                    serde_json::json!({
5746                        "notice": "n".repeat(2_048),
5747                        "license": "l".repeat(2_048),
5748                        "license_url": format!("https://example.invalid/license/{index}/{party}")
5749                    }),
5750                );
5751            }
5752            records.insert(
5753                id.clone(),
5754                serde_json::json!({
5755                    "id": id,
5756                    "title": "t".repeat(1_024),
5757                    "software": [{
5758                        "type": "plugin",
5759                        "name": "n".repeat(1_024),
5760                        "slug": format!("vendor/plugin-{index}.php"),
5761                        "affected_versions": serde_json::Value::Object(affected_versions),
5762                        "patched": true,
5763                        "patched_versions": (0..128)
5764                            .map(|version| format!("{version}.0.2"))
5765                            .collect::<Vec<_>>(),
5766                        "remediation": "m".repeat(2_048)
5767                    }],
5768                    "informational": false,
5769                    "description": "d".repeat(4_096),
5770                    "references": references,
5771                    "cwe": null,
5772                    "cvss": null,
5773                    "cve": null,
5774                    "cve_link": null,
5775                    "researchers": researchers,
5776                    "published": null,
5777                    "updated": null,
5778                    "copyrights": serde_json::Value::Object(copyrights)
5779                }),
5780            );
5781        }
5782        let document = serde_json::to_vec(&serde_json::Value::Object(records)).unwrap();
5783        let import = parse_wordfence_v3_production(document.as_slice()).unwrap();
5784        let inputs = WordPressReviewInputs::new(None, None)
5785            .with_wordfence_v3_catalog(import)
5786            .unwrap();
5787        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
5788        let external = result.external_review().unwrap();
5789
5790        assert_eq!(external.counts().unresolved_identity_associations(), TOTAL);
5791        assert!(external.counts().projected_identity_limitations() > 0);
5792        assert!(external.counts().projected_identity_limitations() < TOTAL);
5793        assert_eq!(
5794            external
5795                .counts()
5796                .projected_identity_limitations()
5797                .checked_add(external.counts().unprojected_identity_limitations()),
5798            Some(TOTAL)
5799        );
5800        assert_eq!(
5801            external.identity_limitations().len(),
5802            external.counts().projected_identity_limitations()
5803        );
5804    }
5805
5806    #[test]
5807    fn explicit_external_profile_produces_nonconstant_range_relations() {
5808        let document = include_str!(
5809            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5810        );
5811        let plugin_context = r#"{
5812          "schema":"security.wordpress-context/v1",
5813          "root":"https://example.test/",
5814          "components":[
5815            {"kind":"plugin","slug":"termivar-fixture-component","version":"1.0"}
5816          ]
5817        }"#;
5818        let numeric = external_review_for(
5819            plugin_context,
5820            document,
5821            WordPressComparisonProfile::NumericDottedV1,
5822        );
5823        let numeric = numeric.external_review().unwrap();
5824        assert_eq!(
5825            numeric.comparison_policy(),
5826            WordPressExternalComparisonPolicy::TermivarWordfenceV3ExplicitInterpretationV1
5827        );
5828        assert_eq!(
5829            numeric.comparison_profile(),
5830            Some(WordPressComparisonProfile::NumericDottedV1)
5831        );
5832        assert_eq!(numeric.counts().within_associations(), 1);
5833        assert_eq!(numeric.counts().outside_associations(), 0);
5834        assert_eq!(numeric.counts().indeterminate_associations(), 0);
5835        assert_eq!(numeric.counts().evaluable_associations(), 1);
5836        assert_eq!(numeric.counts().unsupported_associations(), 0);
5837        assert_eq!(
5838            numeric.evaluations()[0].version_relation(),
5839            WordPressExternalVersionRelation::WithinSupportedRangeUnderSelectedPolicy
5840        );
5841        assert_eq!(
5842            numeric.evaluations()[0].applicability(),
5843            WordPressExternalApplicability::VersionMatchUnderSelectedPolicy
5844        );
5845        assert_eq!(
5846            numeric.evaluations()[0]
5847                .range_evaluations()
5848                .unwrap()
5849                .iter()
5850                .map(WordPressExternalRangeEvaluation::relation)
5851                .collect::<Vec<_>>(),
5852            [
5853                WordPressExternalRangeRelation::Contains,
5854                WordPressExternalRangeRelation::DoesNotContain,
5855            ]
5856        );
5857
5858        let php = external_review_for(
5859            plugin_context,
5860            document,
5861            WordPressComparisonProfile::PhpReleaseSubsetV1,
5862        );
5863        let php = php.external_review().unwrap();
5864        assert_eq!(php.counts().within_associations(), 0);
5865        assert_eq!(php.counts().outside_associations(), 1);
5866        assert_eq!(php.counts().indeterminate_associations(), 0);
5867        assert_eq!(
5868            php.evaluations()[0].version_relation(),
5869            WordPressExternalVersionRelation::OutsideDeclaredRangesUnderSelectedPolicy
5870        );
5871        assert_eq!(
5872            php.evaluations()[0].applicability(),
5873            WordPressExternalApplicability::NoVersionMatchUnderSelectedPolicy
5874        );
5875    }
5876
5877    #[test]
5878    fn source_identity_candidate_is_selected_as_a_limitation_never_as_a_range_conclusion() {
5879        let document = include_str!(
5880            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5881        )
5882        .replacen(
5883            r#""slug": "termivar-fixture-component""#,
5884            r#""slug": "Termivar-Fixture-Component""#,
5885            1,
5886        );
5887        let context = r#"{
5888          "schema":"security.wordpress-context/v1",
5889          "root":"https://example.test/",
5890          "components":[
5891            {"kind":"plugin","slug":"termivar-fixture-component","version":"9.9"}
5892          ]
5893        }"#;
5894        let result = external_review_for(
5895            context,
5896            &document,
5897            WordPressComparisonProfile::NumericDottedV1,
5898        );
5899        let external = result.external_review().unwrap();
5900        assert!(external.requires_audit_v6());
5901        assert_eq!(external.counts().exact_identity_associations(), 2);
5902        assert_eq!(external.counts().candidate_identity_associations(), 1);
5903        assert_eq!(external.counts().ambiguous_identity_associations(), 0);
5904        assert_eq!(external.counts().unresolved_identity_associations(), 0);
5905        assert_eq!(external.counts().selected_associations(), 1);
5906        assert_eq!(external.counts().within_associations(), 0);
5907        assert_eq!(external.counts().outside_associations(), 0);
5908        assert_eq!(external.counts().indeterminate_associations(), 1);
5909        assert_eq!(external.counts().not_evaluated_ranges(), 2);
5910        let evaluation = &external.evaluations()[0];
5911        assert_eq!(
5912            evaluation.identity_mapping(),
5913            WordfenceV3IdentityMapping::AsciiCaseFoldCandidate
5914        );
5915        assert_eq!(
5916            evaluation.key().source_component().slug(),
5917            "Termivar-Fixture-Component"
5918        );
5919        assert_eq!(
5920            evaluation.version_relation(),
5921            WordPressExternalVersionRelation::Indeterminate
5922        );
5923        assert_eq!(
5924            evaluation.version_relation_reason(),
5925            WordPressExternalVersionRelationReason::IdentityMappingCandidate
5926        );
5927        assert_eq!(
5928            evaluation.applicability(),
5929            WordPressExternalApplicability::Indeterminate
5930        );
5931        assert!(evaluation.range_evaluations().unwrap().iter().all(|range| {
5932            range.relation() == WordPressExternalRangeRelation::NotEvaluated
5933                && range.reason() == WordPressExternalRangeReason::IdentityMappingCandidate
5934        }));
5935    }
5936
5937    #[test]
5938    fn explicit_external_profile_keeps_unsupported_and_invalid_ranges_uncertain() {
5939        let base = include_str!(
5940            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
5941        );
5942        let context = r#"{
5943          "schema":"security.wordpress-context/v1",
5944          "root":"https://example.test/",
5945          "components":[
5946            {"kind":"plugin","slug":"termivar-fixture-component","version":"1.1"}
5947          ]
5948        }"#;
5949        let unsupported = base.replacen(
5950            r#""[2.0.0, 2.0.1)": {
5951            "from_version": "2.0.0",
5952            "from_inclusive": true,
5953            "to_version": "2.0.1",
5954            "to_inclusive": false
5955          }"#,
5956            r#""[vendor, vendor-next)": {
5957            "from_version": "vendor",
5958            "from_inclusive": true,
5959            "to_version": "vendor-next",
5960            "to_inclusive": false
5961          }"#,
5962            1,
5963        );
5964        let qualified = external_review_for(
5965            context,
5966            &unsupported,
5967            WordPressComparisonProfile::NumericDottedV1,
5968        );
5969        let qualified = qualified.external_review().unwrap();
5970        assert_eq!(qualified.counts().within_associations(), 1);
5971        assert_eq!(qualified.counts().partial_range_coverage_associations(), 1);
5972        assert_eq!(qualified.counts().unsupported_ranges(), 1);
5973        assert_eq!(
5974            qualified.evaluations()[0].version_relation_reason(),
5975            WordPressExternalVersionRelationReason::ContainingRangeWithPartialCoverage
5976        );
5977
5978        let outside_context = context.replace("1.1", "1.9");
5979        let indeterminate = external_review_for(
5980            &outside_context,
5981            &unsupported,
5982            WordPressComparisonProfile::NumericDottedV1,
5983        );
5984        let indeterminate = indeterminate.external_review().unwrap();
5985        assert_eq!(indeterminate.counts().indeterminate_associations(), 1);
5986        assert_eq!(
5987            indeterminate.evaluations()[0].version_relation_reason(),
5988            WordPressExternalVersionRelationReason::UnsupportedAffectedRange
5989        );
5990
5991        let reversed = base.replacen(
5992            r#""from_version": "1.0.0",
5993            "from_inclusive": true,
5994            "to_version": "1.2.3""#,
5995            r#""from_version": "1.2.3",
5996            "from_inclusive": true,
5997            "to_version": "1.0.0""#,
5998            1,
5999        );
6000        let invalid = external_review_for(
6001            context,
6002            &reversed,
6003            WordPressComparisonProfile::NumericDottedV1,
6004        );
6005        let invalid = invalid.external_review().unwrap();
6006        assert_eq!(invalid.counts().invalid_ranges(), 1);
6007        assert_eq!(invalid.counts().indeterminate_associations(), 1);
6008        assert_eq!(
6009            invalid.evaluations()[0].version_relation_reason(),
6010            WordPressExternalVersionRelationReason::InvalidAffectedRange
6011        );
6012    }
6013
6014    #[test]
6015    fn explicit_external_profile_keeps_boundary_uncertainty_typed_and_reconciled() {
6016        let context_with_version = r#"{
6017          "schema":"security.wordpress-context/v1",
6018          "root":"https://example.test/",
6019          "components":[{"kind":"plugin","slug":"boundary-plugin","version":"1.5"}]
6020        }"#;
6021        let context_without_version = r#"{
6022          "schema":"security.wordpress-context/v1",
6023          "root":"https://example.test/",
6024          "components":[{"kind":"plugin","slug":"boundary-plugin"}]
6025        }"#;
6026        let cases = [
6027            (
6028                "missing version",
6029                context_without_version,
6030                r#"{"range":{"from_version":"1.0","from_inclusive":true,"to_version":"2.0","to_inclusive":true}}"#,
6031                WordPressExternalVersionRelationReason::MissingVersionEvidence,
6032                Some((
6033                    WordPressExternalRangeRelation::NotEvaluated,
6034                    WordPressExternalRangeReason::MissingVersionEvidence,
6035                )),
6036                (1, 0, 0),
6037            ),
6038            (
6039                "unsupported lower",
6040                context_with_version,
6041                r#"{"range":{"from_version":"vendor","from_inclusive":true,"to_version":"2.0","to_inclusive":true}}"#,
6042                WordPressExternalVersionRelationReason::UnsupportedAffectedRange,
6043                Some((
6044                    WordPressExternalRangeRelation::Unsupported,
6045                    WordPressExternalRangeReason::UnsupportedLowerBound,
6046                )),
6047                (0, 1, 0),
6048            ),
6049            (
6050                "unsupported upper",
6051                context_with_version,
6052                r#"{"range":{"from_version":"1.0","from_inclusive":true,"to_version":"vendor","to_inclusive":true}}"#,
6053                WordPressExternalVersionRelationReason::UnsupportedAffectedRange,
6054                Some((
6055                    WordPressExternalRangeRelation::Unsupported,
6056                    WordPressExternalRangeReason::UnsupportedUpperBound,
6057                )),
6058                (0, 1, 0),
6059            ),
6060            (
6061                "equal exclusive interval",
6062                context_with_version,
6063                r#"{"range":{"from_version":"1.5","from_inclusive":false,"to_version":"1.5","to_inclusive":true}}"#,
6064                WordPressExternalVersionRelationReason::InvalidAffectedRange,
6065                Some((
6066                    WordPressExternalRangeRelation::InvalidUnderProfile,
6067                    WordPressExternalRangeReason::EmptyExclusiveInterval,
6068                )),
6069                (0, 0, 1),
6070            ),
6071            (
6072                "missing ranges",
6073                context_with_version,
6074                "{}",
6075                WordPressExternalVersionRelationReason::MissingAffectedRanges,
6076                None,
6077                (0, 0, 0),
6078            ),
6079        ];
6080
6081        for (label, context, ranges, expected_reason, expected_range, expected_counts) in cases {
6082            let document = single_external_association("plugin", "boundary-plugin", ranges);
6083            let result = external_review_for(
6084                context,
6085                &document,
6086                WordPressComparisonProfile::NumericDottedV1,
6087            );
6088            let external = result.external_review().unwrap();
6089            let evaluation = &external.evaluations()[0];
6090            assert_eq!(external.counts().selected_associations(), 1, "{label}");
6091            assert_eq!(external.counts().within_associations(), 0, "{label}");
6092            assert_eq!(external.counts().outside_associations(), 0, "{label}");
6093            assert_eq!(external.counts().indeterminate_associations(), 1, "{label}");
6094            assert_eq!(
6095                evaluation.version_relation(),
6096                WordPressExternalVersionRelation::Indeterminate,
6097                "{label}"
6098            );
6099            assert_eq!(
6100                evaluation.version_relation_reason(),
6101                expected_reason,
6102                "{label}"
6103            );
6104            assert_eq!(
6105                evaluation.applicability(),
6106                WordPressExternalApplicability::Indeterminate,
6107                "{label}"
6108            );
6109            assert_eq!(
6110                (
6111                    external.counts().not_evaluated_ranges(),
6112                    external.counts().unsupported_ranges(),
6113                    external.counts().invalid_ranges(),
6114                ),
6115                expected_counts,
6116                "{label}"
6117            );
6118            match expected_range {
6119                Some((relation, reason)) => {
6120                    let ranges = evaluation.range_evaluations().unwrap();
6121                    assert_eq!(ranges.len(), 1, "{label}");
6122                    assert_eq!(ranges[0].relation(), relation, "{label}");
6123                    assert_eq!(ranges[0].reason(), reason, "{label}");
6124                },
6125                None => assert!(
6126                    evaluation.range_evaluations().unwrap().is_empty(),
6127                    "{label}"
6128                ),
6129            }
6130        }
6131
6132        let conflict_document = single_external_association(
6133            "core",
6134            "wordpress",
6135            r#"{"range":{"from_version":"1.0","from_inclusive":true,"to_version":"2.0","to_inclusive":true}}"#,
6136        );
6137        let context = parse_wordpress_context(
6138            br#"{
6139              "schema":"security.wordpress-context/v1",
6140              "root":"https://example.test/",
6141              "components":[{"kind":"core","slug":"wordpress","version":"1.5"}]
6142            }"#,
6143        )
6144        .unwrap();
6145        let import = parse_wordfence_v3_production(conflict_document.as_bytes()).unwrap();
6146        let inputs = WordPressReviewInputs::new(Some(context), None)
6147            .with_wordfence_v3_catalog(import)
6148            .unwrap()
6149            .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1)
6150            .unwrap();
6151        let result = evaluate_wordpress_review(
6152            &[WordPressComponentSignal::generator_metadata(Some("1.6")).unwrap()],
6153            &inputs,
6154        )
6155        .unwrap();
6156        let external = result.external_review().unwrap();
6157        let evaluation = &external.evaluations()[0];
6158        assert_eq!(external.counts().selected_associations(), 1);
6159        assert_eq!(external.counts().indeterminate_associations(), 1);
6160        assert_eq!(external.counts().not_evaluated_ranges(), 1);
6161        assert_eq!(
6162            evaluation.version_relation_reason(),
6163            WordPressExternalVersionRelationReason::ConflictingVersionEvidence
6164        );
6165        assert_eq!(
6166            evaluation.range_evaluations().unwrap()[0].reason(),
6167            WordPressExternalRangeReason::ConflictingVersionEvidence
6168        );
6169    }
6170
6171    #[test]
6172    fn explicit_external_profile_keeps_patched_and_affected_source_conflicts_indeterminate() {
6173        let document = include_str!(
6174            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
6175        )
6176        .replacen(r#""1.2.4""#, r#""1.1""#, 1);
6177        let context = r#"{
6178          "schema":"security.wordpress-context/v1",
6179          "root":"https://example.test/",
6180          "components":[
6181            {"kind":"plugin","slug":"termivar-fixture-component","version":"1.1"}
6182          ]
6183        }"#;
6184        let result = external_review_for(
6185            context,
6186            &document,
6187            WordPressComparisonProfile::NumericDottedV1,
6188        );
6189        let external = result.external_review().unwrap();
6190        assert_eq!(external.counts().within_associations(), 0);
6191        assert_eq!(external.counts().outside_associations(), 0);
6192        assert_eq!(external.counts().indeterminate_associations(), 1);
6193        assert_eq!(
6194            external.evaluations()[0].version_relation_reason(),
6195            WordPressExternalVersionRelationReason::SourcePatchedVersionWithinAffectedRange
6196        );
6197        assert_eq!(
6198            external.evaluations()[0].applicability(),
6199            WordPressExternalApplicability::Indeterminate
6200        );
6201    }
6202
6203    #[test]
6204    fn explicit_external_profile_resolves_trailing_zeros_per_selected_semantics() {
6205        let document = include_str!(
6206            "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
6207        )
6208        .replacen("[1.0, 1.0.0]", "[1.0.0, 1.0.0]", 1)
6209        .replacen(r#""from_version": "1.0""#, r#""from_version": "1.0.0""#, 1);
6210        let context = r#"{
6211          "schema":"security.wordpress-context/v1",
6212          "root":"https://example.test/",
6213          "components":[{"kind":"core","slug":"wordpress","version":"1.0"}]
6214        }"#;
6215        for (profile, expected) in [
6216            (
6217                WordPressComparisonProfile::NumericDottedV1,
6218                WordPressExternalVersionRelation::WithinSupportedRangeUnderSelectedPolicy,
6219            ),
6220            (
6221                WordPressComparisonProfile::PhpReleaseSubsetV1,
6222                WordPressExternalVersionRelation::OutsideDeclaredRangesUnderSelectedPolicy,
6223            ),
6224        ] {
6225            let result = external_review_for(context, &document, profile);
6226            assert_eq!(
6227                result.external_review().unwrap().evaluations()[0].version_relation(),
6228                expected
6229            );
6230        }
6231    }
6232
6233    #[test]
6234    fn external_profile_requires_exactly_one_attached_wordfence_export() {
6235        assert_eq!(
6236            WordPressReviewInputs::default()
6237                .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1),
6238            Err(WordPressReviewError::ExternalVersionProfileRequiresWordfenceV3)
6239        );
6240        let import = parse_wordfence_v3_production(
6241            &include_bytes!(
6242                "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
6243            )[..],
6244        )
6245        .unwrap();
6246        let selected = WordPressReviewInputs::default()
6247            .with_wordfence_v3_catalog(import)
6248            .unwrap()
6249            .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1)
6250            .unwrap();
6251        assert_eq!(
6252            selected.with_external_version_profile(WordPressComparisonProfile::PhpReleaseSubsetV1),
6253            Err(WordPressReviewError::ExternalVersionProfileRequiresWordfenceV3)
6254        );
6255    }
6256
6257    #[test]
6258    fn native_and_external_advisory_inputs_are_mutually_exclusive() {
6259        let import = parse_wordfence_v3_production(
6260            &include_bytes!(
6261                "../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
6262            )[..],
6263        )
6264        .unwrap();
6265        let native = parse_wordpress_advisory_catalog(CATALOG.as_bytes()).unwrap();
6266        assert_eq!(
6267            WordPressReviewInputs::new(None, Some(native)).with_wordfence_v3_catalog(import),
6268            Err(WordPressReviewError::ConflictingAdvisoryInputs)
6269        );
6270    }
6271
6272    #[test]
6273    fn external_selected_associations_fail_instead_of_truncating() {
6274        let document = wordfence_associations(MAX_WORDPRESS_ADVISORY_RECORDS + 1);
6275        let import = parse_wordfence_v3_production(document.as_bytes()).unwrap();
6276        let context = parse_wordpress_context(
6277            br#"{
6278              "schema":"security.wordpress-context/v1",
6279              "root":"https://example.test/",
6280              "components":[{"kind":"plugin","slug":"selected-plugin","version":"1.0"}]
6281            }"#,
6282        )
6283        .unwrap();
6284        assert_eq!(
6285            evaluate_wordpress_review(
6286                &[],
6287                &WordPressReviewInputs::new(Some(context), None)
6288                    .with_wordfence_v3_catalog(import)
6289                    .unwrap()
6290            ),
6291            Err(WordPressReviewError::ResultLimitExceeded)
6292        );
6293    }
6294
6295    #[test]
6296    fn explicit_external_profile_resolves_one_component_once_for_many_associations() {
6297        let document = wordfence_associations(MAX_WORDPRESS_ADVISORY_RECORDS);
6298        let import = parse_wordfence_v3_production(document.as_bytes()).unwrap();
6299        let context = parse_wordpress_context(
6300            br#"{
6301              "schema":"security.wordpress-context/v1",
6302              "root":"https://example.test/",
6303              "components":[{"kind":"plugin","slug":"selected-plugin","version":"1.0"}]
6304            }"#,
6305        )
6306        .unwrap();
6307        let inputs = WordPressReviewInputs::new(Some(context), None)
6308            .with_wordfence_v3_catalog(import)
6309            .unwrap()
6310            .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1)
6311            .unwrap();
6312        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
6313        let external = result.external_review().unwrap();
6314        assert_eq!(
6315            external.counts().selected_associations(),
6316            MAX_WORDPRESS_ADVISORY_RECORDS
6317        );
6318        assert_eq!(
6319            external.counts().within_associations(),
6320            MAX_WORDPRESS_ADVISORY_RECORDS
6321        );
6322        assert_eq!(external.counts().indeterminate_associations(), 0);
6323    }
6324
6325    #[test]
6326    fn explicit_external_profile_charges_patched_version_range_cartesian_work() {
6327        let work_per_association = crate::wordpress_version::checked_external_interpretation_work(
6328            MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION,
6329            MAX_WORDFENCE_V3_PATCHED_VERSIONS,
6330        )
6331        .unwrap();
6332        let association_count = MAX_WORDPRESS_EVALUATION_WORK / work_per_association + 1;
6333        let document = wordfence_dense_profiled_associations(association_count);
6334        let import = parse_wordfence_v3_production(document.as_bytes()).unwrap();
6335        let context = parse_wordpress_context(
6336            br#"{
6337              "schema":"security.wordpress-context/v1",
6338              "root":"https://example.test/",
6339              "components":[{"kind":"plugin","slug":"selected-plugin","version":"1.0"}]
6340            }"#,
6341        )
6342        .unwrap();
6343        let inputs = WordPressReviewInputs::new(Some(context), None)
6344            .with_wordfence_v3_catalog(import)
6345            .unwrap()
6346            .with_external_version_profile(WordPressComparisonProfile::NumericDottedV1)
6347            .unwrap();
6348        assert_eq!(
6349            evaluate_wordpress_review(&[], &inputs),
6350            Err(WordPressReviewError::EvaluationLimitExceeded)
6351        );
6352    }
6353}