Skip to main content

termivar_scanner/web_runtime/
wordpress_discovery.rs

1//! Bounded, explicitly selected WordPress public-metadata discovery.
2//!
3//! This module owns neither a client nor a budget. Runtime construction lends
4//! it the assessment's exact-origin broker, accounting authority, cancellation
5//! token, and absolute deadline. Retained audit values deliberately omit URLs,
6//! queries, response bodies, and request headers.
7
8use std::collections::BTreeSet;
9
10use serde::{
11    de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
12    Deserialize,
13};
14use sha2::{Digest, Sha256};
15use termivar_core::{
16    ConfidenceScore, DerivationAlgorithm, EntityId, Evidence, EvidenceDerivation, EvidenceId,
17    EvidenceKind, EvidenceSource, EvidenceValue, KnowledgePredicate,
18};
19use tokio_util::sync::CancellationToken;
20use url::Url;
21
22use crate::{
23    http_evidence::{
24        HttpRequestBrokerError, WordPressMetadataRequestDescriptor, WordPressMetadataRequestSource,
25        WordPressMetadataResourceKind, WORDPRESS_METADATA_DISCOVERY_POLICY_ID,
26    },
27    wordpress_review::{
28        WordPressComponentIdentity, WordPressComponentKind, WordPressComponentSignal,
29        WordPressDiscoveryLayout, MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES,
30    },
31    DecisionActionOrigin, DecisionExecutionLimits, DecisionExecutionStage, RuntimeBudgetDimension,
32};
33
34use super::SharedWebRuntimeAuthority;
35
36/// Deployment-aware discovery policy emitted by current assessments.
37pub const WORDPRESS_DISCOVERY_POLICY_ID: &str = WORDPRESS_METADATA_DISCOVERY_POLICY_ID;
38/// Page-scoped discovery policy emitted only for an explicit page-scope selection.
39pub const WORDPRESS_PAGE_SCOPED_DISCOVERY_POLICY_ID: &str =
40    "termivar.wordpress-page-scoped-metadata-discovery/v1";
41/// Supplied-session page observations feeding anonymous metadata discovery.
42pub const WORDPRESS_SUPPLIED_SESSION_DISCOVERY_POLICY_ID: &str =
43    "termivar.wordpress-supplied-session-metadata-discovery/v1";
44/// Bounded form-login context, including honest pre-session empty coverage.
45pub const WORDPRESS_FORM_LOGIN_DISCOVERY_POLICY_ID: &str =
46    "termivar.wordpress-supplied-session-metadata-discovery/v2";
47/// Maximum retained root-derived candidates before per-kind selection.
48pub const MAX_WORDPRESS_DISCOVERY_CANDIDATES: usize = 32;
49/// Maximum broker-owned wire attempts, including failed attempts.
50pub const MAX_WORDPRESS_DISCOVERY_REQUESTS: u8 = 12;
51/// Maximum advertised REST-index GETs.
52pub const MAX_WORDPRESS_DISCOVERY_REST_REQUESTS: u8 = 1;
53/// Maximum theme stylesheet GETs, including one-level parents.
54pub const MAX_WORDPRESS_DISCOVERY_THEME_REQUESTS: u8 = 3;
55/// Maximum plugin readme GETs.
56pub const MAX_WORDPRESS_DISCOVERY_PLUGIN_REQUESTS: u8 = 8;
57/// Maximum distinct root and one-level parent identities considered together.
58pub(super) const MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES: usize = 4_096;
59pub(super) const MAX_WORDPRESS_DISCOVERY_OMITTED_CANDIDATES: u64 =
60    (MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES - MAX_WORDPRESS_DISCOVERY_CANDIDATES) as u64;
61/// Maximum retained REST response bytes before the smaller parent limit wins.
62pub const MAX_WORDPRESS_DISCOVERY_REST_BYTES: usize = 512 * 1024;
63/// Maximum retained bytes for one stylesheet or readme response.
64pub const MAX_WORDPRESS_DISCOVERY_STATIC_BYTES: usize = 128 * 1024;
65/// Maximum response bytes charged to discovery, subject to chunk overrun.
66pub const MAX_WORDPRESS_DISCOVERY_RESPONSE_BYTES: u64 = 2 * 1024 * 1024;
67/// Maximum first-hop page candidates retained before the fixed top-three selection.
68pub const MAX_WORDPRESS_PAGE_CANDIDATES: usize = 32;
69/// Maximum non-entry pages interpreted by one assessment.
70pub const MAX_WORDPRESS_SELECTED_PAGES: usize = 3;
71/// Maximum complete body interpreted for one secondary page.
72pub const MAX_WORDPRESS_PAGE_RESPONSE_BYTES: usize = 256 * 1024;
73/// Maximum secondary-page bytes interpreted across one assessment.
74pub const MAX_WORDPRESS_PAGE_INTERPRETED_BYTES: u64 = 768 * 1024;
75const WORDPRESS_DISCOVERY_ACTION_ID: &str = "web.review.wordpress.metadata-discovery@1";
76const WORDPRESS_DISCOVERY_EVIDENCE_COMPONENT: &str = "wordpress.metadata-discovery";
77const WORDPRESS_DISCOVERY_EVIDENCE_NAMESPACE: &str = "web.wordpress-discovery";
78const MAX_METADATA_HEADER_BYTES: usize = 8 * 1024;
79const MAX_METADATA_VALUE_BYTES: usize = 256;
80const MAX_DISCOVERED_VERSION_BYTES: usize = 64;
81const MAX_REST_NAMESPACES: usize = 64;
82const MAX_REST_NAMESPACE_BYTES: usize = 128;
83const MAX_REST_JSON_DEPTH: usize = 32;
84const MAX_REST_JSON_NODES: usize = 32_768;
85const MAX_REST_JSON_COLLECTION_LENGTH: usize = 4_096;
86const MAX_REST_JSON_KEY_BYTES: usize = 256;
87const MAX_REST_JSON_STRING_BYTES: usize = MAX_METADATA_VALUE_BYTES;
88
89/// Explicit breadth selected for WordPress page-scoped metadata discovery.
90///
91/// This is interpretation/collection policy only. It grants no origin or
92/// credential authority and creates no independent runtime.
93#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
94pub enum WordPressPageScope {
95    Observed,
96}
97
98impl WordPressPageScope {
99    pub const fn as_str(self) -> &'static str {
100        match self {
101            Self::Observed => "observed",
102        }
103    }
104}
105
106/// How one selected non-entry representation became available.
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub enum WordPressPageAcquisition {
109    Reused,
110    Fetched,
111    NotObserved,
112}
113
114impl WordPressPageAcquisition {
115    pub const fn as_str(self) -> &'static str {
116        match self {
117            Self::Reused => "reused",
118            Self::Fetched => "fetched",
119            Self::NotObserved => "not_observed",
120        }
121    }
122}
123
124/// Whether a selected page could be associated with the frozen application.
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
126pub enum WordPressPageAssociation {
127    Accepted,
128    Rejected,
129    NotEstablished,
130}
131
132impl WordPressPageAssociation {
133    pub const fn as_str(self) -> &'static str {
134        match self {
135            Self::Accepted => "accepted",
136            Self::Rejected => "rejected",
137            Self::NotEstablished => "not_established",
138        }
139    }
140}
141
142/// Bounded terminal state for one selected first-hop page.
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum WordPressPageOutcome {
145    Accepted,
146    NoComponentSignals,
147    NotObserved,
148    IncompatibleApplication,
149    Soft404,
150    LoginResponse,
151    UnsupportedContent,
152    Malformed,
153    Truncated,
154    RequestFailed,
155    RateLimited,
156    BudgetExhausted,
157    Cancelled,
158    DeadlineExceeded,
159}
160
161impl WordPressPageOutcome {
162    pub const fn as_str(self) -> &'static str {
163        match self {
164            Self::Accepted => "accepted",
165            Self::NoComponentSignals => "no_component_signals",
166            Self::NotObserved => "not_observed",
167            Self::IncompatibleApplication => "incompatible_application",
168            Self::Soft404 => "soft_404",
169            Self::LoginResponse => "login_response",
170            Self::UnsupportedContent => "unsupported_content",
171            Self::Malformed => "malformed",
172            Self::Truncated => "truncated",
173            Self::RequestFailed => "request_failed",
174            Self::RateLimited => "rate_limited",
175            Self::BudgetExhausted => "budget_exhausted",
176            Self::Cancelled => "cancelled",
177            Self::DeadlineExceeded => "deadline_exceeded",
178        }
179    }
180}
181
182/// Credential mechanism attached to a redaction-safe supplied-session page ledger.
183///
184/// This mirrors the closed supplied-session vocabulary without making the
185/// WordPress-only build depend on credential-bearing runtime types.
186#[derive(Clone, Copy, Debug, Eq, PartialEq)]
187pub enum WordPressSuppliedSessionCredentialMechanism {
188    AuthorizationHeader,
189    CookieJar,
190}
191
192impl WordPressSuppliedSessionCredentialMechanism {
193    pub const fn as_str(self) -> &'static str {
194        match self {
195            Self::AuthorizationHeader => "authorization_header",
196            Self::CookieJar => "cookie_jar",
197        }
198    }
199}
200
201/// WordPress interpretation state for one explicitly selected session resource.
202#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203pub enum WordPressSuppliedSessionPageOutcome {
204    Accepted,
205    NoComponentSignals,
206    IncompatibleApplication,
207    Soft404,
208    LoginResponse,
209    UnsupportedContent,
210    InvalidUtf8,
211    Truncated,
212    NotEvaluated,
213}
214
215impl WordPressSuppliedSessionPageOutcome {
216    pub const fn as_str(self) -> &'static str {
217        match self {
218            Self::Accepted => "accepted",
219            Self::NoComponentSignals => "no_component_signals",
220            Self::IncompatibleApplication => "incompatible_application",
221            Self::Soft404 => "soft_404",
222            Self::LoginResponse => "login_response",
223            Self::UnsupportedContent => "unsupported_content",
224            Self::InvalidUtf8 => "invalid_utf8",
225            Self::Truncated => "truncated",
226            Self::NotEvaluated => "not_evaluated",
227        }
228    }
229
230    pub(super) const fn association(self) -> WordPressPageAssociation {
231        match self {
232            Self::Accepted => WordPressPageAssociation::Accepted,
233            Self::IncompatibleApplication
234            | Self::Soft404
235            | Self::LoginResponse
236            | Self::Truncated => WordPressPageAssociation::Rejected,
237            Self::NoComponentSignals
238            | Self::UnsupportedContent
239            | Self::InvalidUtf8
240            | Self::NotEvaluated => WordPressPageAssociation::NotEstablished,
241        }
242    }
243}
244
245#[derive(Clone, Debug, Eq, PartialEq)]
246pub(in crate::web_runtime) struct WordPressDiscoverySeed {
247    url: Url,
248    application_url: Url,
249    role_base_url: Url,
250    association: WordPressDiscoveryAssociation,
251    kind: WordPressDiscoverySourceKind,
252    component: Option<WordPressComponentIdentity>,
253    parent_depth: u8,
254    preflight_outcome: Option<WordPressDiscoverySourceOutcome>,
255    source_evidence_ids: Vec<EvidenceId>,
256    source_page_references: Vec<String>,
257    source_supplied_session_page_references: Vec<String>,
258}
259
260impl WordPressDiscoverySeed {
261    #[cfg(test)]
262    pub(super) fn rest_index(url: Url) -> Self {
263        let application_url = origin_directory(&url);
264        Self {
265            role_base_url: application_url.clone(),
266            application_url,
267            url,
268            association: WordPressDiscoveryAssociation::StructuredAdvertisement,
269            kind: WordPressDiscoverySourceKind::RestIndex,
270            component: None,
271            parent_depth: 0,
272            preflight_outcome: None,
273            source_evidence_ids: Vec::new(),
274            source_page_references: Vec::new(),
275            source_supplied_session_page_references: Vec::new(),
276        }
277    }
278
279    pub(super) fn invalid_rest_index(document_url: Url) -> Self {
280        Self {
281            role_base_url: document_url.clone(),
282            application_url: document_url.clone(),
283            url: document_url,
284            association: WordPressDiscoveryAssociation::InvalidAdvertisement,
285            kind: WordPressDiscoverySourceKind::RestIndex,
286            component: None,
287            parent_depth: 0,
288            preflight_outcome: Some(WordPressDiscoverySourceOutcome::InvalidAdvertisement),
289            source_evidence_ids: Vec::new(),
290            source_page_references: Vec::new(),
291            source_supplied_session_page_references: Vec::new(),
292        }
293    }
294
295    #[cfg(test)]
296    pub(super) fn component(
297        url: Url,
298        component: WordPressComponentIdentity,
299        parent_depth: u8,
300    ) -> Self {
301        let kind = match component.kind() {
302            WordPressComponentKind::Theme => WordPressDiscoverySourceKind::ThemeStylesheet,
303            WordPressComponentKind::Plugin => WordPressDiscoverySourceKind::PluginReadme,
304            WordPressComponentKind::Core => WordPressDiscoverySourceKind::RestIndex,
305        };
306        let role_base_url =
307            inferred_component_role_base(&url).unwrap_or_else(|| origin_directory(&url));
308        Self {
309            application_url: origin_directory(&url),
310            role_base_url,
311            url,
312            association: WordPressDiscoveryAssociation::ObservedConventional,
313            kind,
314            component: Some(component),
315            parent_depth,
316            preflight_outcome: None,
317            source_evidence_ids: Vec::new(),
318            source_page_references: Vec::new(),
319            source_supplied_session_page_references: Vec::new(),
320        }
321    }
322
323    pub(super) fn admitted_rest_index(
324        url: Url,
325        application_url: Url,
326        role_base_url: Url,
327        association: WordPressDiscoveryAssociation,
328    ) -> Self {
329        Self {
330            url,
331            application_url,
332            role_base_url,
333            association,
334            kind: WordPressDiscoverySourceKind::RestIndex,
335            component: None,
336            parent_depth: 0,
337            preflight_outcome: None,
338            source_evidence_ids: Vec::new(),
339            source_page_references: Vec::new(),
340            source_supplied_session_page_references: Vec::new(),
341        }
342    }
343
344    pub(super) fn admitted_component(
345        url: Url,
346        application_url: Url,
347        role_base_url: Url,
348        association: WordPressDiscoveryAssociation,
349        component: WordPressComponentIdentity,
350        parent_depth: u8,
351    ) -> Self {
352        let kind = match component.kind() {
353            WordPressComponentKind::Theme => WordPressDiscoverySourceKind::ThemeStylesheet,
354            WordPressComponentKind::Plugin => WordPressDiscoverySourceKind::PluginReadme,
355            WordPressComponentKind::Core => WordPressDiscoverySourceKind::RestIndex,
356        };
357        Self {
358            url,
359            application_url,
360            role_base_url,
361            association,
362            kind,
363            component: Some(component),
364            parent_depth,
365            preflight_outcome: None,
366            source_evidence_ids: Vec::new(),
367            source_page_references: Vec::new(),
368            source_supplied_session_page_references: Vec::new(),
369        }
370    }
371
372    pub(super) const fn kind(&self) -> WordPressDiscoverySourceKind {
373        self.kind
374    }
375
376    pub(super) const fn url(&self) -> &Url {
377        &self.url
378    }
379
380    pub(super) const fn component_identity(&self) -> Option<&WordPressComponentIdentity> {
381        self.component.as_ref()
382    }
383
384    pub(super) const fn parent_depth(&self) -> u8 {
385        self.parent_depth
386    }
387
388    pub(super) const fn preflight_outcome(&self) -> Option<WordPressDiscoverySourceOutcome> {
389        self.preflight_outcome
390    }
391
392    pub(super) const fn association(&self) -> WordPressDiscoveryAssociation {
393        self.association
394    }
395
396    pub(super) const fn role_base_url(&self) -> &Url {
397        &self.role_base_url
398    }
399
400    pub(super) const fn application_url(&self) -> &Url {
401        &self.application_url
402    }
403
404    pub(super) fn resource_reference(&self) -> String {
405        opaque_url_reference("wordpress-discovery-resource", &self.url)
406    }
407
408    pub(super) fn role_reference(&self) -> Option<String> {
409        self.preflight_outcome
410            .is_none()
411            .then(|| opaque_url_reference("wordpress-discovery-role", &self.role_base_url))
412    }
413
414    fn dispatch_contract_is_valid(&self) -> bool {
415        if self.url.origin() != self.application_url.origin()
416            || self.role_base_url.origin() != self.application_url.origin()
417            || !safe_directory_url(&self.application_url)
418            || !safe_directory_url(&self.role_base_url)
419            || !safe_resource_url(&self.url)
420        {
421            return false;
422        }
423        if self.preflight_outcome.is_some() {
424            return self.kind == WordPressDiscoverySourceKind::RestIndex
425                && self.component.is_none()
426                && self.parent_depth == 0
427                && self.url == self.application_url
428                && self.role_base_url == self.application_url
429                && self.preflight_outcome
430                    == Some(WordPressDiscoverySourceOutcome::InvalidAdvertisement);
431        }
432        match self.kind {
433            WordPressDiscoverySourceKind::RestIndex => {
434                self.component.is_none()
435                    && admitted_rest_resource(&self.application_url, &self.role_base_url, &self.url)
436            },
437            WordPressDiscoverySourceKind::ThemeStylesheet
438            | WordPressDiscoverySourceKind::PluginReadme => {
439                let Some(component) = self.component.as_ref() else {
440                    return false;
441                };
442                let expected_kind = match self.kind {
443                    WordPressDiscoverySourceKind::ThemeStylesheet => WordPressComponentKind::Theme,
444                    WordPressDiscoverySourceKind::PluginReadme => WordPressComponentKind::Plugin,
445                    WordPressDiscoverySourceKind::RestIndex => return false,
446                };
447                component.kind() == expected_kind
448                    && resource_is_exact_role_child(
449                        &self.role_base_url,
450                        component.slug(),
451                        match self.kind {
452                            WordPressDiscoverySourceKind::ThemeStylesheet => "style.css",
453                            WordPressDiscoverySourceKind::PluginReadme => "readme.txt",
454                            WordPressDiscoverySourceKind::RestIndex => return false,
455                        },
456                        &self.url,
457                    )
458            },
459        }
460    }
461
462    fn request_descriptor(
463        &self,
464        source_evidence_count: usize,
465    ) -> Result<WordPressMetadataRequestDescriptor, WordPressDiscoveryExecutionError> {
466        let resource_kind = match self.kind {
467            WordPressDiscoverySourceKind::RestIndex => WordPressMetadataResourceKind::RestIndex,
468            WordPressDiscoverySourceKind::ThemeStylesheet => {
469                let component = self
470                    .component
471                    .as_ref()
472                    .ok_or(WordPressDiscoveryExecutionError::EvidenceModel)?;
473                WordPressMetadataResourceKind::ThemeStylesheet {
474                    slug: component.slug().to_owned(),
475                }
476            },
477            WordPressDiscoverySourceKind::PluginReadme => {
478                let component = self
479                    .component
480                    .as_ref()
481                    .ok_or(WordPressDiscoveryExecutionError::EvidenceModel)?;
482                WordPressMetadataResourceKind::PluginReadme {
483                    slug: component.slug().to_owned(),
484                }
485            },
486        };
487        let source = match self.association {
488            WordPressDiscoveryAssociation::StructuredAdvertisement => {
489                WordPressMetadataRequestSource::StructuredAdvertisement
490            },
491            WordPressDiscoveryAssociation::OperatorQualifiedAdvertisement => {
492                WordPressMetadataRequestSource::OperatorQualifiedAdvertisement
493            },
494            WordPressDiscoveryAssociation::ObservedConventional => {
495                WordPressMetadataRequestSource::ObservedConventional
496            },
497            WordPressDiscoveryAssociation::ExplicitOperator => {
498                WordPressMetadataRequestSource::ExplicitOperator
499            },
500            WordPressDiscoveryAssociation::SameThemeBaseParent => {
501                WordPressMetadataRequestSource::SameThemeBaseParent
502            },
503            WordPressDiscoveryAssociation::InvalidAdvertisement => {
504                return Err(WordPressDiscoveryExecutionError::EvidenceModel);
505            },
506        };
507        WordPressMetadataRequestDescriptor::from_source_evidence(
508            WORDPRESS_DISCOVERY_POLICY_ID,
509            &self.application_url,
510            &self.role_base_url,
511            &self.url,
512            resource_kind,
513            source,
514            source_evidence_count,
515        )
516        .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)
517    }
518
519    pub(super) fn with_source_evidence(mut self, evidence_id: EvidenceId) -> Self {
520        if !self.source_evidence_ids.contains(&evidence_id) {
521            self.source_evidence_ids.push(evidence_id);
522        }
523        self
524    }
525
526    pub(super) fn with_source_page_reference(mut self, reference: String) -> Self {
527        if !self.source_page_references.contains(&reference) {
528            self.source_page_references.push(reference);
529            self.source_page_references.sort();
530        }
531        self
532    }
533
534    #[cfg(feature = "supplied-session-review")]
535    pub(super) fn with_source_supplied_session_page_reference(mut self, reference: String) -> Self {
536        if !self
537            .source_supplied_session_page_references
538            .contains(&reference)
539        {
540            self.source_supplied_session_page_references.push(reference);
541            self.source_supplied_session_page_references.sort();
542        }
543        self
544    }
545
546    #[cfg(feature = "supplied-session-review")]
547    pub(super) fn without_source_page_references(mut self) -> Self {
548        self.source_page_references.clear();
549        self
550    }
551
552    pub(super) fn merge_source_provenance(&mut self, other: &Self) {
553        for evidence_id in &other.source_evidence_ids {
554            if !self.source_evidence_ids.contains(evidence_id) {
555                self.source_evidence_ids.push(evidence_id.clone());
556            }
557        }
558        self.source_evidence_ids.sort();
559        for reference in &other.source_page_references {
560            if !self.source_page_references.contains(reference) {
561                self.source_page_references.push(reference.clone());
562            }
563        }
564        self.source_page_references.sort();
565        for reference in &other.source_supplied_session_page_references {
566            if !self
567                .source_supplied_session_page_references
568                .contains(reference)
569            {
570                self.source_supplied_session_page_references
571                    .push(reference.clone());
572            }
573        }
574        self.source_supplied_session_page_references.sort();
575    }
576
577    pub(super) fn source_page_references(&self) -> &[String] {
578        &self.source_page_references
579    }
580
581    pub(super) fn source_supplied_session_page_references(&self) -> &[String] {
582        &self.source_supplied_session_page_references
583    }
584
585    fn source_evidence_ids<'a>(&'a self, root: &'a [EvidenceId]) -> &'a [EvidenceId] {
586        if self.source_evidence_ids.is_empty() {
587            root
588        } else {
589            &self.source_evidence_ids
590        }
591    }
592
593    pub(super) fn sort_key(&self) -> (u8, &str, &str, &str, &str) {
594        let rank = match self.kind {
595            WordPressDiscoverySourceKind::RestIndex => 0,
596            WordPressDiscoverySourceKind::ThemeStylesheet => 1,
597            WordPressDiscoverySourceKind::PluginReadme => 2,
598        };
599        let slug = self
600            .component
601            .as_ref()
602            .map_or("", WordPressComponentIdentity::slug);
603        let preflight = self
604            .preflight_outcome
605            .map_or("", |outcome| outcome.as_str());
606        (
607            rank,
608            slug,
609            self.role_base_url.as_str(),
610            self.url.as_str(),
611            preflight,
612        )
613    }
614}
615
616pub(super) fn discovery_seed_fingerprint(seed: &WordPressDiscoverySeed) -> [u8; 32] {
617    let mut digest = Sha256::new();
618    let component_kind = seed
619        .component_identity()
620        .map_or("", |component| match component.kind() {
621            WordPressComponentKind::Core => "core",
622            WordPressComponentKind::Plugin => "plugin",
623            WordPressComponentKind::Theme => "theme",
624        });
625    for value in [
626        seed.sort_key().0.to_string(),
627        seed.url().as_str().to_owned(),
628        seed.application_url.as_str().to_owned(),
629        seed.role_base_url.as_str().to_owned(),
630        component_kind.to_owned(),
631        seed.component_identity()
632            .map_or(String::new(), |component| component.slug().to_owned()),
633        seed.preflight_outcome()
634            .map_or(String::new(), |outcome| outcome.as_str().to_owned()),
635    ] {
636        digest.update(
637            u64::try_from(value.len())
638                .expect("bounded candidate identity length fits u64")
639                .to_be_bytes(),
640        );
641        digest.update(value.as_bytes());
642    }
643    digest.finalize().into()
644}
645
646#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
647pub enum WordPressDiscoveryAssociation {
648    StructuredAdvertisement,
649    OperatorQualifiedAdvertisement,
650    ObservedConventional,
651    ExplicitOperator,
652    SameThemeBaseParent,
653    InvalidAdvertisement,
654}
655
656impl WordPressDiscoveryAssociation {
657    pub const fn as_str(self) -> &'static str {
658        match self {
659            Self::StructuredAdvertisement => "structured_advertisement",
660            Self::OperatorQualifiedAdvertisement => "operator_qualified_advertisement",
661            Self::ObservedConventional => "observed_conventional",
662            Self::ExplicitOperator => "explicit_operator",
663            Self::SameThemeBaseParent => "same_theme_base_parent",
664            Self::InvalidAdvertisement => "invalid_advertisement",
665        }
666    }
667}
668
669#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
670pub enum WordPressDiscoveryLayoutRole {
671    Core,
672    Themes,
673    Plugins,
674    RestIndex,
675}
676
677impl WordPressDiscoveryLayoutRole {
678    pub const fn as_str(self) -> &'static str {
679        match self {
680            Self::Core => "core",
681            Self::Themes => "themes",
682            Self::Plugins => "plugins",
683            Self::RestIndex => "rest_index",
684        }
685    }
686}
687
688#[derive(Clone, Copy, Debug, Eq, PartialEq)]
689pub enum WordPressDiscoveryLayoutStatus {
690    Exact,
691    Ambiguous,
692    Unresolved,
693}
694
695impl WordPressDiscoveryLayoutStatus {
696    pub const fn as_str(self) -> &'static str {
697        match self {
698            Self::Exact => "exact",
699            Self::Ambiguous => "ambiguous",
700            Self::Unresolved => "unresolved",
701        }
702    }
703}
704
705#[derive(Clone, Copy, Debug, Eq, PartialEq)]
706pub enum WordPressDiscoveryLayoutBasis {
707    ConventionalAsset,
708    StructuredAdvertisement,
709    OperatorDeclaration,
710    None,
711}
712
713impl WordPressDiscoveryLayoutBasis {
714    pub const fn as_str(self) -> &'static str {
715        match self {
716            Self::ConventionalAsset => "conventional_asset",
717            Self::StructuredAdvertisement => "structured_advertisement",
718            Self::OperatorDeclaration => "operator_declaration",
719            Self::None => "none",
720        }
721    }
722}
723
724#[derive(Clone, Debug, Eq, PartialEq)]
725pub struct WordPressDiscoveryLayoutRoleAudit {
726    pub(super) role: WordPressDiscoveryLayoutRole,
727    pub(super) status: WordPressDiscoveryLayoutStatus,
728    pub(super) basis: WordPressDiscoveryLayoutBasis,
729    pub(super) reference: Option<String>,
730    pub(super) candidate_count: u16,
731}
732
733impl WordPressDiscoveryLayoutRoleAudit {
734    pub const fn role(&self) -> WordPressDiscoveryLayoutRole {
735        self.role
736    }
737    pub const fn status(&self) -> WordPressDiscoveryLayoutStatus {
738        self.status
739    }
740    pub const fn basis(&self) -> WordPressDiscoveryLayoutBasis {
741        self.basis
742    }
743    pub fn reference(&self) -> Option<&str> {
744        self.reference.as_deref()
745    }
746    pub const fn candidate_count(&self) -> u16 {
747        self.candidate_count
748    }
749}
750
751#[derive(Clone, Debug, Eq, PartialEq)]
752pub struct WordPressDiscoveryLayoutDeclarationAudit {
753    pub(super) byte_length: u64,
754    pub(super) sha256: String,
755}
756
757impl WordPressDiscoveryLayoutDeclarationAudit {
758    pub const fn byte_length(&self) -> u64 {
759        self.byte_length
760    }
761    pub fn sha256(&self) -> &str {
762        &self.sha256
763    }
764}
765
766#[derive(Clone, Debug, Eq, PartialEq)]
767pub struct WordPressDiscoveryLayoutAudit {
768    pub(super) application_reference: String,
769    pub(super) declaration: Option<WordPressDiscoveryLayoutDeclarationAudit>,
770    pub(super) roles: Vec<WordPressDiscoveryLayoutRoleAudit>,
771    pub(super) skipped_foreign_origin_count: u16,
772    pub(super) skipped_sibling_application_count: u16,
773    pub(super) conflicting_association_count: u16,
774}
775
776impl WordPressDiscoveryLayoutAudit {
777    pub fn unresolved(application_url: &Url, layout: Option<&WordPressDiscoveryLayout>) -> Self {
778        let declaration = layout.map(|layout| WordPressDiscoveryLayoutDeclarationAudit {
779            byte_length: u64::try_from(layout.byte_length()).unwrap_or(u64::MAX),
780            sha256: lowercase_sha256(layout.sha256()),
781        });
782        let roles = [
783            WordPressDiscoveryLayoutRole::Core,
784            WordPressDiscoveryLayoutRole::Themes,
785            WordPressDiscoveryLayoutRole::Plugins,
786            WordPressDiscoveryLayoutRole::RestIndex,
787        ]
788        .into_iter()
789        .map(|role| {
790            let declared = layout.and_then(|layout| match role {
791                WordPressDiscoveryLayoutRole::Core => layout.core_base_url(),
792                WordPressDiscoveryLayoutRole::Themes => layout.themes_base_url(),
793                WordPressDiscoveryLayoutRole::Plugins => layout.plugins_base_url(),
794                WordPressDiscoveryLayoutRole::RestIndex => None,
795            });
796            WordPressDiscoveryLayoutRoleAudit {
797                role,
798                status: if declared.is_some() {
799                    WordPressDiscoveryLayoutStatus::Exact
800                } else {
801                    WordPressDiscoveryLayoutStatus::Unresolved
802                },
803                basis: if declared.is_some() {
804                    WordPressDiscoveryLayoutBasis::OperatorDeclaration
805                } else {
806                    WordPressDiscoveryLayoutBasis::None
807                },
808                reference: declared
809                    .map(|base| opaque_url_reference("wordpress-discovery-role", base)),
810                candidate_count: u16::from(declared.is_some()),
811            }
812        })
813        .collect();
814        Self {
815            application_reference: opaque_url_reference(
816                "wordpress-selected-application",
817                application_url,
818            ),
819            declaration,
820            roles,
821            skipped_foreign_origin_count: 0,
822            skipped_sibling_application_count: 0,
823            conflicting_association_count: 0,
824        }
825    }
826
827    pub fn application_reference(&self) -> &str {
828        &self.application_reference
829    }
830    pub const fn declaration(&self) -> Option<&WordPressDiscoveryLayoutDeclarationAudit> {
831        self.declaration.as_ref()
832    }
833    pub fn roles(&self) -> &[WordPressDiscoveryLayoutRoleAudit] {
834        &self.roles
835    }
836    pub const fn skipped_foreign_origin_count(&self) -> u16 {
837        self.skipped_foreign_origin_count
838    }
839    pub const fn skipped_sibling_application_count(&self) -> u16 {
840        self.skipped_sibling_application_count
841    }
842    pub const fn conflicting_association_count(&self) -> u16 {
843        self.conflicting_association_count
844    }
845
846    pub(super) fn role_mut(
847        &mut self,
848        role: WordPressDiscoveryLayoutRole,
849    ) -> &mut WordPressDiscoveryLayoutRoleAudit {
850        self.roles
851            .iter_mut()
852            .find(|entry| entry.role == role)
853            .expect("the closed layout audit always contains every role")
854    }
855}
856
857fn lowercase_sha256(value: &[u8; 32]) -> String {
858    value.iter().map(|byte| format!("{byte:02x}")).collect()
859}
860
861pub(super) fn opaque_url_reference(domain: &str, url: &Url) -> String {
862    let mut digest = Sha256::new();
863    for value in [domain.as_bytes(), url.as_str().as_bytes()] {
864        digest.update(
865            u64::try_from(value.len())
866                .expect("bounded URL reference length fits u64")
867                .to_be_bytes(),
868        );
869        digest.update(value);
870    }
871    format!("sha256:{}", lowercase_sha256(&digest.finalize().into()))
872}
873
874#[cfg(test)]
875fn origin_directory(url: &Url) -> Url {
876    let mut origin = url.clone();
877    origin.set_path("/");
878    origin.set_query(None);
879    origin.set_fragment(None);
880    origin
881}
882
883#[cfg(test)]
884fn inferred_component_role_base(url: &Url) -> Option<Url> {
885    let mut segments = url.path_segments()?.collect::<Vec<_>>();
886    if segments.last().is_some_and(|segment| segment.is_empty()) {
887        segments.pop();
888    }
889    if segments.len() < 3 {
890        return None;
891    }
892    segments.truncate(segments.len().saturating_sub(2));
893    directory_with_segments(url, &segments)
894}
895
896#[cfg(test)]
897fn directory_with_segments(source: &Url, segments: &[&str]) -> Option<Url> {
898    let mut url = origin_directory(source);
899    let path = if segments.is_empty() {
900        "/".to_owned()
901    } else {
902        format!("/{}/", segments.join("/"))
903    };
904    url.set_path(&path);
905    safe_directory_url(&url).then_some(url)
906}
907
908fn safe_directory_url(url: &Url) -> bool {
909    matches!(url.scheme(), "http" | "https")
910        && url.has_host()
911        && url.username().is_empty()
912        && url.password().is_none()
913        && url.query().is_none()
914        && url.fragment().is_none()
915        && url.path().starts_with('/')
916        && url.path().ends_with('/')
917        && safe_normalized_path(url.path())
918}
919
920fn safe_resource_url(url: &Url) -> bool {
921    matches!(url.scheme(), "http" | "https")
922        && url.has_host()
923        && url.username().is_empty()
924        && url.password().is_none()
925        && url.fragment().is_none()
926        && safe_normalized_path(url.path())
927}
928
929fn safe_normalized_path(path: &str) -> bool {
930    !path.contains('\\')
931        && !path.split('/').skip(1).enumerate().any(|(index, segment)| {
932            (segment.is_empty() && index + 2 < path.split('/').count())
933                || matches!(segment, "." | "..")
934                || ["%2e", "%2f", "%5c", "%25"]
935                    .iter()
936                    .any(|encoded| segment.to_ascii_lowercase().contains(encoded))
937        })
938}
939
940fn resource_is_exact_role_child(base: &Url, slug: &str, filename: &str, resource: &Url) -> bool {
941    if resource.query().is_some() || resource.fragment().is_some() {
942        return false;
943    }
944    let Some(expected) = base.join(&format!("{slug}/{filename}")).ok() else {
945        return false;
946    };
947    expected == *resource
948}
949
950fn admitted_rest_resource(application: &Url, role_base: &Url, resource: &Url) -> bool {
951    if resource.origin() != application.origin() || role_base.origin() != application.origin() {
952        return false;
953    }
954    let pretty = role_base
955        .join("wp-json/")
956        .ok()
957        .is_some_and(|expected| expected == *resource && resource.query().is_none());
958    let plain_base = resource.path() == role_base.path()
959        || role_base
960            .join("index.php")
961            .ok()
962            .is_some_and(|expected| expected.path() == resource.path());
963    pretty
964        || (plain_base
965            && resource
966                .query()
967                .is_some_and(crate::http_evidence::wordpress_rest_route_query_is_admitted))
968}
969
970#[derive(Clone, Copy, Debug, Eq, PartialEq)]
971pub(crate) enum WordPressDiscoveryStop {
972    Complete,
973    Cancelled,
974    DeadlineExceeded,
975    RuntimeLimit(RuntimeBudgetDimension),
976    DiscoveryResponseLimit,
977}
978
979pub(super) struct WordPressDiscoveryExecution {
980    pub(super) audit: WebAssessmentWordPressDiscoveryAudit,
981    pub(super) signals: Vec<WordPressComponentSignal>,
982    pub(super) stop: WordPressDiscoveryStop,
983}
984
985pub(super) struct WordPressDiscoveryExecutionInput {
986    candidates: Vec<WordPressDiscoverySeed>,
987    omitted_candidate_count: u64,
988    considered_candidate_fingerprints: BTreeSet<[u8; 32]>,
989    root_evidence_ids: Vec<EvidenceId>,
990    subject: EntityId,
991    layout: WordPressDiscoveryLayoutAudit,
992}
993
994impl WordPressDiscoveryExecutionInput {
995    pub(super) fn new(
996        candidates: Vec<WordPressDiscoverySeed>,
997        omitted_candidate_count: u64,
998        considered_candidate_fingerprints: BTreeSet<[u8; 32]>,
999        root_evidence_ids: Vec<EvidenceId>,
1000        subject: EntityId,
1001        layout: WordPressDiscoveryLayoutAudit,
1002    ) -> Self {
1003        Self {
1004            candidates,
1005            omitted_candidate_count,
1006            considered_candidate_fingerprints,
1007            root_evidence_ids,
1008            subject,
1009            layout,
1010        }
1011    }
1012}
1013
1014#[derive(Debug)]
1015pub(in crate::web_runtime) enum WordPressDiscoveryExecutionError {
1016    CandidateIdentityLimitExceeded,
1017    RootEvidenceUnavailable,
1018    EvidenceModel,
1019    EvidenceCommit,
1020}
1021
1022/// Closed public-metadata source classes.
1023#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
1024pub enum WordPressDiscoverySourceKind {
1025    RestIndex,
1026    ThemeStylesheet,
1027    PluginReadme,
1028}
1029
1030impl WordPressDiscoverySourceKind {
1031    pub const fn as_str(self) -> &'static str {
1032        match self {
1033            Self::RestIndex => "rest_index",
1034            Self::ThemeStylesheet => "theme_stylesheet",
1035            Self::PluginReadme => "plugin_readme",
1036        }
1037    }
1038}
1039
1040/// Bounded terminal state for one candidate.
1041#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1042pub enum WordPressDiscoverySourceOutcome {
1043    Observed,
1044    NoMetadata,
1045    NotFound,
1046    Unauthorized,
1047    RateLimited,
1048    RedirectObserved,
1049    UnsupportedContent,
1050    InvalidAdvertisement,
1051    Malformed,
1052    Truncated,
1053    RequestFailed,
1054    BudgetExhausted,
1055    Cancelled,
1056    DeadlineExceeded,
1057    NotSelectedByLimit,
1058    NotAttemptedAfterThrottle,
1059}
1060
1061impl WordPressDiscoverySourceOutcome {
1062    pub const fn as_str(self) -> &'static str {
1063        match self {
1064            Self::Observed => "observed",
1065            Self::NoMetadata => "no_metadata",
1066            Self::NotFound => "not_found",
1067            Self::Unauthorized => "unauthorized",
1068            Self::RateLimited => "rate_limited",
1069            Self::RedirectObserved => "redirect_observed",
1070            Self::UnsupportedContent => "unsupported_content",
1071            Self::InvalidAdvertisement => "invalid_advertisement",
1072            Self::Malformed => "malformed",
1073            Self::Truncated => "truncated",
1074            Self::RequestFailed => "request_failed",
1075            Self::BudgetExhausted => "budget_exhausted",
1076            Self::Cancelled => "cancelled",
1077            Self::DeadlineExceeded => "deadline_exceeded",
1078            Self::NotSelectedByLimit => "not_selected_by_limit",
1079            Self::NotAttemptedAfterThrottle => "not_attempted_after_throttle",
1080        }
1081    }
1082
1083    pub(crate) const fn is_completed_response(self) -> bool {
1084        matches!(
1085            self,
1086            Self::Observed
1087                | Self::NoMetadata
1088                | Self::NotFound
1089                | Self::Unauthorized
1090                | Self::RateLimited
1091                | Self::RedirectObserved
1092                | Self::UnsupportedContent
1093                | Self::Malformed
1094                | Self::Truncated
1095        )
1096    }
1097}
1098
1099/// Bounded fields retained from one theme stylesheet header.
1100#[derive(Clone, Debug, Eq, PartialEq)]
1101pub struct WordPressThemeDiscoveryMetadata {
1102    pub(super) name: Option<String>,
1103    pub(super) version: Option<String>,
1104    pub(super) template: Option<String>,
1105    pub(super) requires_wordpress: Option<String>,
1106    pub(super) requires_php: Option<String>,
1107    pub(super) tested_up_to: Option<String>,
1108}
1109
1110impl WordPressThemeDiscoveryMetadata {
1111    pub fn name(&self) -> Option<&str> {
1112        self.name.as_deref()
1113    }
1114    pub fn version(&self) -> Option<&str> {
1115        self.version.as_deref()
1116    }
1117    pub fn template(&self) -> Option<&str> {
1118        self.template.as_deref()
1119    }
1120    pub fn requires_wordpress(&self) -> Option<&str> {
1121        self.requires_wordpress.as_deref()
1122    }
1123    pub fn requires_php(&self) -> Option<&str> {
1124        self.requires_php.as_deref()
1125    }
1126    pub fn tested_up_to(&self) -> Option<&str> {
1127        self.tested_up_to.as_deref()
1128    }
1129}
1130
1131/// Bounded fields retained from one plugin distribution readme.
1132#[derive(Clone, Debug, Eq, PartialEq)]
1133pub struct WordPressPluginDiscoveryMetadata {
1134    pub(super) name: Option<String>,
1135    pub(super) stable_tag: Option<String>,
1136    pub(super) requires_wordpress: Option<String>,
1137    pub(super) requires_php: Option<String>,
1138    pub(super) tested_up_to: Option<String>,
1139}
1140
1141/// Redaction-safe record for one selected non-entry page.
1142#[derive(Clone, Debug, Eq, PartialEq)]
1143pub struct WordPressPageAudit {
1144    pub(super) page_reference: String,
1145    pub(super) acquisition: WordPressPageAcquisition,
1146    pub(super) association: WordPressPageAssociation,
1147    pub(super) outcome: WordPressPageOutcome,
1148    pub(super) request_attempted: bool,
1149    pub(super) interpreted_response_bytes: u64,
1150    pub(super) response_bytes: u64,
1151    pub(super) evidence_ids: Vec<EvidenceId>,
1152}
1153
1154impl WordPressPageAudit {
1155    pub fn page_reference(&self) -> &str {
1156        &self.page_reference
1157    }
1158    pub const fn acquisition(&self) -> WordPressPageAcquisition {
1159        self.acquisition
1160    }
1161    pub const fn association(&self) -> WordPressPageAssociation {
1162        self.association
1163    }
1164    pub const fn outcome(&self) -> WordPressPageOutcome {
1165        self.outcome
1166    }
1167    pub const fn request_attempted(&self) -> bool {
1168        self.request_attempted
1169    }
1170    pub const fn interpreted_response_bytes(&self) -> u64 {
1171        self.interpreted_response_bytes
1172    }
1173    pub const fn response_bytes(&self) -> u64 {
1174        self.response_bytes
1175    }
1176    pub fn evidence_ids(&self) -> &[EvidenceId] {
1177        &self.evidence_ids
1178    }
1179}
1180
1181/// Separate bounded accounting for explicit page-scoped discovery.
1182#[derive(Clone, Debug, Eq, PartialEq)]
1183pub struct WordPressPageScopeAudit {
1184    pub(super) mode: WordPressPageScope,
1185    pub(super) entry_page_reference: String,
1186    pub(super) candidate_count: u16,
1187    pub(super) selected_count: u8,
1188    pub(super) omitted_candidate_count: u16,
1189    pub(super) reused_response_count: u8,
1190    pub(super) fetched_response_count: u8,
1191    pub(super) not_observed_count: u8,
1192    pub(super) rejected_response_count: u8,
1193    pub(super) accepted_association_count: u8,
1194    pub(super) rejected_association_count: u8,
1195    pub(super) attempted_request_count: u8,
1196    pub(super) completed_response_count: u8,
1197    pub(super) committed_response_count: u8,
1198    pub(super) interpreted_response_bytes: u64,
1199    pub(super) response_bytes: u64,
1200    pub(super) pages: Vec<WordPressPageAudit>,
1201}
1202
1203/// Redaction-safe relationship between one supplied-session resource and the
1204/// WordPress facts interpreted from its health-qualified HTML representation.
1205#[derive(Clone, Debug, Eq, PartialEq)]
1206pub struct WordPressSuppliedSessionPageAudit {
1207    pub(super) context_page_reference: String,
1208    pub(super) resource_reference: String,
1209    pub(super) resource_evidence_reference: Option<String>,
1210    pub(super) association: WordPressPageAssociation,
1211    pub(super) outcome: WordPressSuppliedSessionPageOutcome,
1212    pub(super) interpreted_response_bytes: u64,
1213    pub(super) evidence_ids: Vec<EvidenceId>,
1214}
1215
1216impl WordPressSuppliedSessionPageAudit {
1217    pub fn context_page_reference(&self) -> &str {
1218        &self.context_page_reference
1219    }
1220    pub fn resource_reference(&self) -> &str {
1221        &self.resource_reference
1222    }
1223    pub fn resource_evidence_reference(&self) -> Option<&str> {
1224        self.resource_evidence_reference.as_deref()
1225    }
1226    pub const fn association(&self) -> WordPressPageAssociation {
1227        self.association
1228    }
1229    pub const fn outcome(&self) -> WordPressSuppliedSessionPageOutcome {
1230        self.outcome
1231    }
1232    pub const fn interpreted_response_bytes(&self) -> u64 {
1233        self.interpreted_response_bytes
1234    }
1235    pub fn evidence_ids(&self) -> &[EvidenceId] {
1236        &self.evidence_ids
1237    }
1238    pub const fn fingerprint_evaluation(&self) -> &'static str {
1239        "not_selected_in_v1"
1240    }
1241}
1242
1243#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1244pub(super) enum WordPressSuppliedSessionPageAcquisition {
1245    Ready,
1246    FormLogin,
1247}
1248
1249/// Separate bounded ledger for supplied-session page interpretation.
1250///
1251/// Metadata sources nominated by accepted rows are still retrieved anonymously;
1252/// the credential mechanism describes only how the source HTML was obtained.
1253#[derive(Clone, Debug, Eq, PartialEq)]
1254pub struct WordPressSuppliedSessionPageScopeAudit {
1255    pub(super) acquisition: WordPressSuppliedSessionPageAcquisition,
1256    pub(super) policy_reference: String,
1257    pub(super) application_reference: String,
1258    pub(super) principal_reference: String,
1259    pub(super) credential_mechanism: WordPressSuppliedSessionCredentialMechanism,
1260    pub(super) epoch: u8,
1261    pub(super) selected_count: u8,
1262    pub(super) committed_count: u8,
1263    pub(super) accepted_count: u8,
1264    pub(super) rejected_count: u8,
1265    pub(super) not_established_count: u8,
1266    pub(super) not_evaluated_count: u8,
1267    pub(super) interpreted_response_bytes: u64,
1268    pub(super) pages: Vec<WordPressSuppliedSessionPageAudit>,
1269}
1270
1271impl WordPressSuppliedSessionPageScopeAudit {
1272    pub const fn mode(&self) -> &'static str {
1273        match self.acquisition {
1274            WordPressSuppliedSessionPageAcquisition::Ready => {
1275                "committed_supplied_session_resources"
1276            },
1277            WordPressSuppliedSessionPageAcquisition::FormLogin => {
1278                "form_login_supplied_session_resources"
1279            },
1280        }
1281    }
1282    pub const fn policy_id(&self) -> &'static str {
1283        match self.acquisition {
1284            WordPressSuppliedSessionPageAcquisition::Ready => {
1285                WORDPRESS_SUPPLIED_SESSION_DISCOVERY_POLICY_ID
1286            },
1287            WordPressSuppliedSessionPageAcquisition::FormLogin => {
1288                WORDPRESS_FORM_LOGIN_DISCOVERY_POLICY_ID
1289            },
1290        }
1291    }
1292    pub fn policy_reference(&self) -> &str {
1293        &self.policy_reference
1294    }
1295    pub fn application_reference(&self) -> &str {
1296        &self.application_reference
1297    }
1298    pub fn principal_reference(&self) -> &str {
1299        &self.principal_reference
1300    }
1301    pub const fn credential_mechanism(&self) -> WordPressSuppliedSessionCredentialMechanism {
1302        self.credential_mechanism
1303    }
1304    pub const fn epoch(&self) -> u8 {
1305        self.epoch
1306    }
1307    pub const fn selected_count(&self) -> u8 {
1308        self.selected_count
1309    }
1310    pub const fn committed_count(&self) -> u8 {
1311        self.committed_count
1312    }
1313    pub const fn accepted_count(&self) -> u8 {
1314        self.accepted_count
1315    }
1316    pub const fn rejected_count(&self) -> u8 {
1317        self.rejected_count
1318    }
1319    pub const fn not_established_count(&self) -> u8 {
1320        self.not_established_count
1321    }
1322    pub const fn not_evaluated_count(&self) -> u8 {
1323        self.not_evaluated_count
1324    }
1325    pub const fn interpreted_response_bytes(&self) -> u64 {
1326        self.interpreted_response_bytes
1327    }
1328    pub fn pages(&self) -> &[WordPressSuppliedSessionPageAudit] {
1329        &self.pages
1330    }
1331}
1332
1333impl WordPressPageScopeAudit {
1334    pub const fn mode(&self) -> WordPressPageScope {
1335        self.mode
1336    }
1337    pub fn entry_page_reference(&self) -> &str {
1338        &self.entry_page_reference
1339    }
1340    pub const fn candidate_count(&self) -> u16 {
1341        self.candidate_count
1342    }
1343    pub const fn selected_count(&self) -> u8 {
1344        self.selected_count
1345    }
1346    pub const fn omitted_candidate_count(&self) -> u16 {
1347        self.omitted_candidate_count
1348    }
1349    pub const fn reused_response_count(&self) -> u8 {
1350        self.reused_response_count
1351    }
1352    pub const fn fetched_response_count(&self) -> u8 {
1353        self.fetched_response_count
1354    }
1355    pub const fn not_observed_count(&self) -> u8 {
1356        self.not_observed_count
1357    }
1358    pub const fn rejected_response_count(&self) -> u8 {
1359        self.rejected_response_count
1360    }
1361    pub const fn accepted_association_count(&self) -> u8 {
1362        self.accepted_association_count
1363    }
1364    pub const fn rejected_association_count(&self) -> u8 {
1365        self.rejected_association_count
1366    }
1367    pub const fn attempted_request_count(&self) -> u8 {
1368        self.attempted_request_count
1369    }
1370    pub const fn completed_response_count(&self) -> u8 {
1371        self.completed_response_count
1372    }
1373    pub const fn committed_response_count(&self) -> u8 {
1374        self.committed_response_count
1375    }
1376    pub const fn interpreted_response_bytes(&self) -> u64 {
1377        self.interpreted_response_bytes
1378    }
1379    pub const fn response_bytes(&self) -> u64 {
1380        self.response_bytes
1381    }
1382    pub fn pages(&self) -> &[WordPressPageAudit] {
1383        &self.pages
1384    }
1385}
1386
1387impl WordPressPluginDiscoveryMetadata {
1388    pub fn name(&self) -> Option<&str> {
1389        self.name.as_deref()
1390    }
1391    pub fn stable_tag(&self) -> Option<&str> {
1392        self.stable_tag.as_deref()
1393    }
1394    pub fn requires_wordpress(&self) -> Option<&str> {
1395        self.requires_wordpress.as_deref()
1396    }
1397    pub fn requires_php(&self) -> Option<&str> {
1398        self.requires_php.as_deref()
1399    }
1400    pub fn tested_up_to(&self) -> Option<&str> {
1401        self.tested_up_to.as_deref()
1402    }
1403}
1404
1405/// Redaction-safe result for one deterministic metadata candidate.
1406#[derive(Clone, Debug, Eq, PartialEq)]
1407pub struct WordPressDiscoverySourceAudit {
1408    pub(super) kind: WordPressDiscoverySourceKind,
1409    pub(super) component: Option<WordPressComponentIdentity>,
1410    pub(super) parent_depth: u8,
1411    pub(super) outcome: WordPressDiscoverySourceOutcome,
1412    pub(super) request_attempted: bool,
1413    pub(super) response_bytes: u64,
1414    pub(super) evidence_ids: Vec<EvidenceId>,
1415    pub(super) namespaces: Vec<String>,
1416    pub(super) theme: Option<WordPressThemeDiscoveryMetadata>,
1417    pub(super) plugin: Option<WordPressPluginDiscoveryMetadata>,
1418    pub(super) association: WordPressDiscoveryAssociation,
1419    pub(super) resource_reference: String,
1420    pub(super) role_reference: Option<String>,
1421    pub(super) source_page_references: Vec<String>,
1422    pub(super) source_supplied_session_page_references: Vec<String>,
1423}
1424
1425impl WordPressDiscoverySourceAudit {
1426    pub const fn kind(&self) -> WordPressDiscoverySourceKind {
1427        self.kind
1428    }
1429    pub const fn component_kind(&self) -> Option<WordPressComponentKind> {
1430        match &self.component {
1431            Some(value) => Some(value.kind()),
1432            None => None,
1433        }
1434    }
1435    pub fn component_slug(&self) -> Option<&str> {
1436        self.component
1437            .as_ref()
1438            .map(WordPressComponentIdentity::slug)
1439    }
1440    pub const fn parent_depth(&self) -> u8 {
1441        self.parent_depth
1442    }
1443    pub const fn outcome(&self) -> WordPressDiscoverySourceOutcome {
1444        self.outcome
1445    }
1446    pub const fn request_attempted(&self) -> bool {
1447        self.request_attempted
1448    }
1449    pub const fn response_bytes(&self) -> u64 {
1450        self.response_bytes
1451    }
1452    pub fn evidence_ids(&self) -> &[EvidenceId] {
1453        &self.evidence_ids
1454    }
1455    pub fn namespaces(&self) -> &[String] {
1456        &self.namespaces
1457    }
1458    pub const fn theme(&self) -> Option<&WordPressThemeDiscoveryMetadata> {
1459        self.theme.as_ref()
1460    }
1461    pub const fn plugin(&self) -> Option<&WordPressPluginDiscoveryMetadata> {
1462        self.plugin.as_ref()
1463    }
1464    pub const fn association(&self) -> WordPressDiscoveryAssociation {
1465        self.association
1466    }
1467    pub fn resource_reference(&self) -> &str {
1468        &self.resource_reference
1469    }
1470    pub fn role_reference(&self) -> Option<&str> {
1471        self.role_reference.as_deref()
1472    }
1473    pub fn source_page_references(&self) -> &[String] {
1474        &self.source_page_references
1475    }
1476    pub fn source_supplied_session_page_references(&self) -> &[String] {
1477        &self.source_supplied_session_page_references
1478    }
1479}
1480
1481/// Versioned, redaction-safe discovery audit retained beside the legacy
1482/// transport-free WordPress review audit.
1483#[derive(Clone, Debug, Eq, PartialEq)]
1484pub struct WebAssessmentWordPressDiscoveryAudit {
1485    pub(super) seed_count: u8,
1486    pub(super) candidate_count: u8,
1487    pub(super) candidate_limit_reached: bool,
1488    pub(super) omitted_candidate_count: u64,
1489    pub(super) attempted_request_count: u8,
1490    pub(super) completed_response_count: u8,
1491    pub(super) committed_response_count: u8,
1492    pub(super) response_bytes: u64,
1493    pub(super) sources: Vec<WordPressDiscoverySourceAudit>,
1494    pub(super) layout: WordPressDiscoveryLayoutAudit,
1495    pub(super) page_scope: Option<WordPressPageScopeAudit>,
1496    pub(super) supplied_session_pages: Option<WordPressSuppliedSessionPageScopeAudit>,
1497}
1498
1499impl WebAssessmentWordPressDiscoveryAudit {
1500    pub const fn policy_id(&self) -> &'static str {
1501        if let Some(scope) = self.supplied_session_pages.as_ref() {
1502            scope.policy_id()
1503        } else if self.page_scope.is_some() {
1504            WORDPRESS_PAGE_SCOPED_DISCOVERY_POLICY_ID
1505        } else {
1506            WORDPRESS_DISCOVERY_POLICY_ID
1507        }
1508    }
1509    pub const fn selected(&self) -> bool {
1510        true
1511    }
1512    pub const fn seed_count(&self) -> u8 {
1513        self.seed_count
1514    }
1515    pub const fn candidate_count(&self) -> u8 {
1516        self.candidate_count
1517    }
1518    /// True when at least one distinct admitted candidate was deliberately
1519    /// omitted by the fixed 32-candidate selection ceiling.
1520    pub const fn candidate_limit_reached(&self) -> bool {
1521        self.candidate_limit_reached
1522    }
1523    /// Exact number of distinct candidate details omitted by the fixed
1524    /// candidate ceiling during this bounded selection.
1525    pub const fn omitted_candidate_count(&self) -> u64 {
1526        self.omitted_candidate_count
1527    }
1528    pub const fn attempted_request_count(&self) -> u8 {
1529        self.attempted_request_count
1530    }
1531    pub const fn completed_response_count(&self) -> u8 {
1532        self.completed_response_count
1533    }
1534    pub const fn committed_response_count(&self) -> u8 {
1535        self.committed_response_count
1536    }
1537    pub const fn response_bytes(&self) -> u64 {
1538        self.response_bytes
1539    }
1540    pub fn sources(&self) -> &[WordPressDiscoverySourceAudit] {
1541        &self.sources
1542    }
1543    pub const fn layout(&self) -> &WordPressDiscoveryLayoutAudit {
1544        &self.layout
1545    }
1546    pub const fn page_scope(&self) -> Option<&WordPressPageScopeAudit> {
1547        self.page_scope.as_ref()
1548    }
1549    pub const fn supplied_session_pages(&self) -> Option<&WordPressSuppliedSessionPageScopeAudit> {
1550        self.supplied_session_pages.as_ref()
1551    }
1552
1553    pub(crate) fn is_internally_consistent(&self) -> bool {
1554        let source_bytes = self.sources.iter().try_fold(0_u64, |total, source| {
1555            total.checked_add(source.response_bytes)
1556        });
1557        let evidence_references = self.sources.iter().try_fold(0_usize, |total, source| {
1558            total.checked_add(source.evidence_ids.len())
1559        });
1560        let completed_sources = self
1561            .sources
1562            .iter()
1563            .filter(|source| source.outcome.is_completed_response())
1564            .count();
1565        let evidence_shape_valid = self.sources.iter().all(|source| {
1566            source.evidence_ids.len() == usize::from(source.outcome.is_completed_response())
1567        });
1568        let attempted_sources = self
1569            .sources
1570            .iter()
1571            .filter(|source| source.request_attempted)
1572            .count();
1573        let attempt_shape_valid = self.sources.iter().all(|source| {
1574            let completed_response_was_attempted =
1575                !source.outcome.is_completed_response() || source.request_attempted;
1576            let unattempted_source_has_no_response_bytes =
1577                source.request_attempted || source.response_bytes == 0;
1578            let definitely_not_dispatched = matches!(
1579                source.outcome,
1580                WordPressDiscoverySourceOutcome::InvalidAdvertisement
1581                    | WordPressDiscoverySourceOutcome::NotSelectedByLimit
1582                    | WordPressDiscoverySourceOutcome::NotAttemptedAfterThrottle
1583            );
1584            let dispatch_state_matches_outcome =
1585                !definitely_not_dispatched || !source.request_attempted;
1586            completed_response_was_attempted
1587                && unattempted_source_has_no_response_bytes
1588                && dispatch_state_matches_outcome
1589        });
1590        let unique_sources = self
1591            .sources
1592            .iter()
1593            .map(|source| source.resource_reference.as_str())
1594            .collect::<std::collections::BTreeSet<_>>()
1595            .len();
1596        usize::from(self.seed_count) <= MAX_WORDPRESS_DISCOVERY_CANDIDATES
1597            && usize::from(self.candidate_count) <= MAX_WORDPRESS_DISCOVERY_CANDIDATES
1598            && self.seed_count <= self.candidate_count
1599            && self.candidate_limit_reached == (self.omitted_candidate_count > 0)
1600            && self.omitted_candidate_count <= MAX_WORDPRESS_DISCOVERY_OMITTED_CANDIDATES
1601            && (self.omitted_candidate_count == 0
1602                || usize::from(self.candidate_count) == MAX_WORDPRESS_DISCOVERY_CANDIDATES)
1603            && self.attempted_request_count <= MAX_WORDPRESS_DISCOVERY_REQUESTS
1604            && self.attempted_request_count <= self.candidate_count
1605            && attempted_sources == usize::from(self.attempted_request_count)
1606            && attempted_request_classes_within_limits(&self.sources)
1607            && self.completed_response_count <= self.attempted_request_count
1608            && completed_sources == usize::from(self.completed_response_count)
1609            && self.committed_response_count == self.completed_response_count
1610            && usize::from(self.candidate_count) == self.sources.len()
1611            && unique_sources == self.sources.len()
1612            && source_bytes == Some(self.response_bytes)
1613            && evidence_shape_valid
1614            && attempt_shape_valid
1615            && evidence_references == Some(usize::from(self.committed_response_count))
1616            && discovery_layout_is_consistent(&self.layout, &self.sources)
1617            && page_scope_is_consistent(self)
1618            && supplied_session_pages_are_consistent(self)
1619    }
1620}
1621
1622fn page_scope_is_consistent(audit: &WebAssessmentWordPressDiscoveryAudit) -> bool {
1623    let Some(page_scope) = audit.page_scope.as_ref() else {
1624        return audit
1625            .sources
1626            .iter()
1627            .all(|source| source.source_page_references.is_empty());
1628    };
1629    let page_references = page_scope
1630        .pages
1631        .iter()
1632        .map(|page| page.page_reference.as_str())
1633        .collect::<BTreeSet<_>>();
1634    let interpreted_bytes = page_scope.pages.iter().try_fold(0_u64, |total, page| {
1635        total.checked_add(page.interpreted_response_bytes)
1636    });
1637    let response_bytes = page_scope
1638        .pages
1639        .iter()
1640        .try_fold(0_u64, |total, page| total.checked_add(page.response_bytes));
1641    let reused = page_scope
1642        .pages
1643        .iter()
1644        .filter(|page| page.acquisition == WordPressPageAcquisition::Reused)
1645        .count();
1646    let fetched = page_scope
1647        .pages
1648        .iter()
1649        .filter(|page| page.acquisition == WordPressPageAcquisition::Fetched)
1650        .count();
1651    let not_observed = page_scope
1652        .pages
1653        .iter()
1654        .filter(|page| page.acquisition == WordPressPageAcquisition::NotObserved)
1655        .count();
1656    let rejected = page_scope
1657        .pages
1658        .iter()
1659        .filter(|page| page.association == WordPressPageAssociation::Rejected)
1660        .count();
1661    let accepted = page_scope
1662        .pages
1663        .iter()
1664        .filter(|page| page.association == WordPressPageAssociation::Accepted)
1665        .count();
1666    let attempted = page_scope
1667        .pages
1668        .iter()
1669        .filter(|page| page.request_attempted)
1670        .count();
1671    let committed = page_scope.pages.iter().try_fold(0_usize, |total, page| {
1672        total.checked_add(page.evidence_ids.len())
1673    });
1674    valid_opaque_reference(&page_scope.entry_page_reference)
1675        && usize::from(page_scope.selected_count) == page_scope.pages.len()
1676        && usize::from(page_scope.selected_count) <= MAX_WORDPRESS_SELECTED_PAGES
1677        && usize::from(page_scope.candidate_count) <= MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
1678        && usize::from(page_scope.candidate_count)
1679            == usize::from(page_scope.selected_count)
1680                .saturating_add(usize::from(page_scope.omitted_candidate_count))
1681        && page_references.len() == page_scope.pages.len()
1682        && page_references
1683            .iter()
1684            .all(|reference| valid_opaque_reference(reference))
1685        && reused == usize::from(page_scope.reused_response_count)
1686        && fetched == usize::from(page_scope.fetched_response_count)
1687        && not_observed == usize::from(page_scope.not_observed_count)
1688        && rejected == usize::from(page_scope.rejected_response_count)
1689        && rejected == usize::from(page_scope.rejected_association_count)
1690        && accepted == usize::from(page_scope.accepted_association_count)
1691        && attempted == usize::from(page_scope.attempted_request_count)
1692        && page_scope.completed_response_count
1693            == page_scope
1694                .reused_response_count
1695                .saturating_add(page_scope.fetched_response_count)
1696        && committed == Some(usize::from(page_scope.committed_response_count))
1697        && interpreted_bytes == Some(page_scope.interpreted_response_bytes)
1698        && response_bytes == Some(page_scope.response_bytes)
1699        && page_scope.interpreted_response_bytes <= MAX_WORDPRESS_PAGE_INTERPRETED_BYTES
1700        && page_scope.response_bytes <= MAX_WORDPRESS_DISCOVERY_RESPONSE_BYTES
1701        && audit
1702            .attempted_request_count
1703            .saturating_add(page_scope.attempted_request_count)
1704            <= MAX_WORDPRESS_DISCOVERY_REQUESTS
1705        && audit
1706            .response_bytes
1707            .checked_add(page_scope.response_bytes)
1708            .is_some_and(|total| total <= MAX_WORDPRESS_DISCOVERY_RESPONSE_BYTES)
1709        && audit.sources.iter().all(|source| {
1710            !source.source_page_references.is_empty()
1711                && source
1712                    .source_page_references
1713                    .windows(2)
1714                    .all(|pair| pair[0] < pair[1])
1715                && source.source_page_references.iter().all(|reference| {
1716                    reference == &page_scope.entry_page_reference
1717                        || page_scope.pages.iter().any(|page| {
1718                            page.page_reference == *reference
1719                                && page.association == WordPressPageAssociation::Accepted
1720                        })
1721                })
1722        })
1723        && page_scope.pages.iter().all(|page| {
1724            let has_evidence = page.evidence_ids.len() == 1;
1725            let terminal_shape = match page.association {
1726                WordPressPageAssociation::Accepted => {
1727                    page.outcome == WordPressPageOutcome::Accepted && has_evidence
1728                },
1729                WordPressPageAssociation::Rejected => {
1730                    !matches!(
1731                        page.outcome,
1732                        WordPressPageOutcome::Accepted
1733                            | WordPressPageOutcome::NoComponentSignals
1734                            | WordPressPageOutcome::NotObserved
1735                    ) && has_evidence
1736                },
1737                WordPressPageAssociation::NotEstablished => {
1738                    matches!(
1739                        page.outcome,
1740                        WordPressPageOutcome::NoComponentSignals
1741                            | WordPressPageOutcome::NotObserved
1742                    ) && (page.outcome == WordPressPageOutcome::NotObserved) != has_evidence
1743                },
1744            };
1745            let acquisition_shape = match page.acquisition {
1746                WordPressPageAcquisition::Reused => {
1747                    !page.request_attempted && page.response_bytes == 0 && has_evidence
1748                },
1749                WordPressPageAcquisition::Fetched => page.request_attempted && has_evidence,
1750                WordPressPageAcquisition::NotObserved => {
1751                    !page.request_attempted
1752                        && page.response_bytes == 0
1753                        && page.interpreted_response_bytes == 0
1754                        && !has_evidence
1755                        && page.outcome == WordPressPageOutcome::NotObserved
1756                },
1757            };
1758            terminal_shape
1759                && acquisition_shape
1760                && page.interpreted_response_bytes
1761                    <= u64::try_from(MAX_WORDPRESS_PAGE_RESPONSE_BYTES).unwrap_or(u64::MAX)
1762        })
1763}
1764
1765fn supplied_session_pages_are_consistent(audit: &WebAssessmentWordPressDiscoveryAudit) -> bool {
1766    let Some(scope) = audit.supplied_session_pages.as_ref() else {
1767        return audit
1768            .sources
1769            .iter()
1770            .all(|source| source.source_supplied_session_page_references.is_empty());
1771    };
1772    let selected = scope.pages.len();
1773    let committed = scope
1774        .pages
1775        .iter()
1776        .filter(|page| page.resource_evidence_reference.is_some())
1777        .count();
1778    let accepted = scope
1779        .pages
1780        .iter()
1781        .filter(|page| page.association == WordPressPageAssociation::Accepted)
1782        .count();
1783    let rejected = scope
1784        .pages
1785        .iter()
1786        .filter(|page| page.association == WordPressPageAssociation::Rejected)
1787        .count();
1788    let not_established = scope
1789        .pages
1790        .iter()
1791        .filter(|page| page.association == WordPressPageAssociation::NotEstablished)
1792        .count();
1793    let not_evaluated = scope
1794        .pages
1795        .iter()
1796        .filter(|page| {
1797            matches!(
1798                page.outcome,
1799                WordPressSuppliedSessionPageOutcome::UnsupportedContent
1800                    | WordPressSuppliedSessionPageOutcome::InvalidUtf8
1801                    | WordPressSuppliedSessionPageOutcome::NotEvaluated
1802            )
1803        })
1804        .count();
1805    let interpreted_bytes = scope.pages.iter().try_fold(0_u64, |total, page| {
1806        total.checked_add(page.interpreted_response_bytes)
1807    });
1808    let references = scope
1809        .pages
1810        .iter()
1811        .map(|page| page.context_page_reference.as_str())
1812        .collect::<BTreeSet<_>>();
1813    let resources = scope
1814        .pages
1815        .iter()
1816        .map(|page| page.resource_reference.as_str())
1817        .collect::<BTreeSet<_>>();
1818    valid_prefixed_sha256(&scope.policy_reference, "supplied-session-policy-sha256:")
1819        && valid_prefixed_sha256(
1820            &scope.application_reference,
1821            "supplied-session-application-sha256:",
1822        )
1823        && scope.principal_reference == "supplied-session-principal-0001"
1824        && match scope.acquisition {
1825            WordPressSuppliedSessionPageAcquisition::Ready => scope.epoch == 1,
1826            WordPressSuppliedSessionPageAcquisition::FormLogin => {
1827                scope.epoch == 1
1828                    || (scope.epoch == 0
1829                        && committed == 0
1830                        && accepted == 0
1831                        && rejected == 0
1832                        && not_established == selected
1833                        && not_evaluated == selected
1834                        && scope.interpreted_response_bytes == 0
1835                        && scope.pages.iter().all(|page| {
1836                            page.outcome == WordPressSuppliedSessionPageOutcome::NotEvaluated
1837                                && page.evidence_ids.is_empty()
1838                                && page.resource_evidence_reference.is_none()
1839                        }))
1840            },
1841        }
1842        && selected == usize::from(scope.selected_count)
1843        && committed == usize::from(scope.committed_count)
1844        && accepted == usize::from(scope.accepted_count)
1845        && rejected == usize::from(scope.rejected_count)
1846        && not_established == usize::from(scope.not_established_count)
1847        && not_evaluated == usize::from(scope.not_evaluated_count)
1848        && selected
1849            == accepted
1850                .saturating_add(rejected)
1851                .saturating_add(not_established)
1852        && references.len() == selected
1853        && resources.len() == selected
1854        && interpreted_bytes == Some(scope.interpreted_response_bytes)
1855        && scope.interpreted_response_bytes <= MAX_WORDPRESS_PAGE_INTERPRETED_BYTES
1856        && scope.pages.iter().all(|page| {
1857            let interpreted = !matches!(
1858                page.outcome,
1859                WordPressSuppliedSessionPageOutcome::UnsupportedContent
1860                    | WordPressSuppliedSessionPageOutcome::InvalidUtf8
1861                    | WordPressSuppliedSessionPageOutcome::NotEvaluated
1862            );
1863            valid_opaque_reference(&page.context_page_reference)
1864                && valid_prefixed_sha256(
1865                    &page.resource_reference,
1866                    "supplied-session-resource-sha256:",
1867                )
1868                && page
1869                    .resource_evidence_reference
1870                    .as_deref()
1871                    .is_none_or(|reference| {
1872                        valid_prefixed_sha256(
1873                            reference,
1874                            "supplied-session-resource-evidence-sha256:",
1875                        )
1876                    })
1877                && page.association == page.outcome.association()
1878                && page.evidence_ids.len() == usize::from(interpreted)
1879                && (!interpreted || page.resource_evidence_reference.is_some())
1880                && page.interpreted_response_bytes
1881                    <= u64::try_from(MAX_WORDPRESS_PAGE_RESPONSE_BYTES).unwrap_or(u64::MAX)
1882        })
1883        && audit.sources.iter().all(|source| {
1884            source
1885                .source_supplied_session_page_references
1886                .windows(2)
1887                .all(|pair| pair[0] < pair[1])
1888                && source
1889                    .source_supplied_session_page_references
1890                    .iter()
1891                    .all(|reference| {
1892                        scope.pages.iter().any(|page| {
1893                            page.context_page_reference == *reference
1894                                && page.association == WordPressPageAssociation::Accepted
1895                        })
1896                    })
1897        })
1898}
1899
1900fn discovery_layout_is_consistent(
1901    layout: &WordPressDiscoveryLayoutAudit,
1902    sources: &[WordPressDiscoverySourceAudit],
1903) -> bool {
1904    let role_order = [
1905        WordPressDiscoveryLayoutRole::Core,
1906        WordPressDiscoveryLayoutRole::Themes,
1907        WordPressDiscoveryLayoutRole::Plugins,
1908        WordPressDiscoveryLayoutRole::RestIndex,
1909    ];
1910    let declaration_valid = layout.declaration.as_ref().is_none_or(|declaration| {
1911        declaration.byte_length > 0
1912            && declaration.byte_length
1913                <= u64::try_from(MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES).unwrap_or(u64::MAX)
1914            && valid_sha256_digest(&declaration.sha256)
1915    });
1916    if !valid_opaque_reference(&layout.application_reference)
1917        || !declaration_valid
1918        || layout.roles.len() != role_order.len()
1919        || layout
1920            .roles
1921            .iter()
1922            .zip(role_order)
1923            .any(|(entry, expected)| {
1924                entry.role != expected
1925                    || match entry.status {
1926                        WordPressDiscoveryLayoutStatus::Exact => {
1927                            entry
1928                                .reference
1929                                .as_deref()
1930                                .is_none_or(|reference| !valid_opaque_reference(reference))
1931                                || entry.candidate_count == 0
1932                                || entry.basis == WordPressDiscoveryLayoutBasis::None
1933                        },
1934                        WordPressDiscoveryLayoutStatus::Ambiguous => {
1935                            entry.reference.is_some()
1936                                || entry.candidate_count < 2
1937                                || entry.basis == WordPressDiscoveryLayoutBasis::None
1938                        },
1939                        WordPressDiscoveryLayoutStatus::Unresolved => {
1940                            entry.reference.is_some()
1941                                || entry.candidate_count != 0
1942                                || entry.basis != WordPressDiscoveryLayoutBasis::None
1943                        },
1944                    }
1945            })
1946    {
1947        return false;
1948    }
1949    sources.iter().all(|source| {
1950        let role = match source.kind {
1951            WordPressDiscoverySourceKind::RestIndex => WordPressDiscoveryLayoutRole::RestIndex,
1952            WordPressDiscoverySourceKind::ThemeStylesheet => WordPressDiscoveryLayoutRole::Themes,
1953            WordPressDiscoverySourceKind::PluginReadme => WordPressDiscoveryLayoutRole::Plugins,
1954        };
1955        let exact_role_binding = source.role_reference.as_deref().is_some_and(|reference| {
1956            layout.roles.iter().any(|entry| {
1957                entry.role == role
1958                    && entry.status == WordPressDiscoveryLayoutStatus::Exact
1959                    && entry.reference.as_deref() == Some(reference)
1960            }) && valid_opaque_reference(reference)
1961        });
1962        let binding_required = source.request_attempted
1963            || source.outcome.is_completed_response()
1964            || source.theme.is_some()
1965            || source.plugin.is_some()
1966            || !source.namespaces.is_empty();
1967        let association_valid = match source.association {
1968            WordPressDiscoveryAssociation::StructuredAdvertisement => {
1969                role == WordPressDiscoveryLayoutRole::RestIndex && source.role_reference.is_some()
1970            },
1971            WordPressDiscoveryAssociation::OperatorQualifiedAdvertisement => {
1972                role == WordPressDiscoveryLayoutRole::RestIndex && source.role_reference.is_some()
1973            },
1974            WordPressDiscoveryAssociation::ObservedConventional => {
1975                matches!(
1976                    role,
1977                    WordPressDiscoveryLayoutRole::Themes | WordPressDiscoveryLayoutRole::Plugins
1978                ) && source.role_reference.is_some()
1979            },
1980            WordPressDiscoveryAssociation::ExplicitOperator => {
1981                matches!(
1982                    role,
1983                    WordPressDiscoveryLayoutRole::Themes | WordPressDiscoveryLayoutRole::Plugins
1984                ) && source.role_reference.is_some()
1985            },
1986            WordPressDiscoveryAssociation::SameThemeBaseParent => {
1987                role == WordPressDiscoveryLayoutRole::Themes
1988                    && source.parent_depth == 1
1989                    && source.role_reference.is_some()
1990            },
1991            WordPressDiscoveryAssociation::InvalidAdvertisement => {
1992                role == WordPressDiscoveryLayoutRole::RestIndex
1993                    && source.outcome == WordPressDiscoverySourceOutcome::InvalidAdvertisement
1994                    && source.role_reference.is_none()
1995            },
1996        };
1997        association_valid
1998            && valid_opaque_reference(&source.resource_reference)
1999            && ((!binding_required && source.role_reference.is_none()) || exact_role_binding)
2000    })
2001}
2002
2003fn valid_sha256_digest(value: &str) -> bool {
2004    value.len() == 64
2005        && value
2006            .bytes()
2007            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2008}
2009
2010fn valid_prefixed_sha256(value: &str, prefix: &str) -> bool {
2011    value.strip_prefix(prefix).is_some_and(valid_sha256_digest)
2012}
2013
2014fn valid_opaque_reference(value: &str) -> bool {
2015    value.strip_prefix("sha256:").is_some_and(|digest| {
2016        digest.len() == 64
2017            && digest
2018                .bytes()
2019                .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2020    })
2021}
2022
2023fn attempted_request_classes_within_limits(sources: &[WordPressDiscoverySourceAudit]) -> bool {
2024    let mut rest = 0_u8;
2025    let mut themes = 0_u8;
2026    let mut plugins = 0_u8;
2027    for source in sources {
2028        if !source.request_attempted {
2029            continue;
2030        }
2031        match source.kind {
2032            WordPressDiscoverySourceKind::RestIndex => rest = rest.saturating_add(1),
2033            WordPressDiscoverySourceKind::ThemeStylesheet => themes = themes.saturating_add(1),
2034            WordPressDiscoverySourceKind::PluginReadme => plugins = plugins.saturating_add(1),
2035        }
2036    }
2037    rest <= MAX_WORDPRESS_DISCOVERY_REST_REQUESTS
2038        && themes <= MAX_WORDPRESS_DISCOVERY_THEME_REQUESTS
2039        && plugins <= MAX_WORDPRESS_DISCOVERY_PLUGIN_REQUESTS
2040}
2041
2042pub(super) async fn execute_wordpress_discovery(
2043    input: WordPressDiscoveryExecutionInput,
2044    authority: &SharedWebRuntimeAuthority,
2045    deadline: Option<tokio::time::Instant>,
2046) -> Result<WordPressDiscoveryExecution, WordPressDiscoveryExecutionError> {
2047    let WordPressDiscoveryExecutionInput {
2048        mut candidates,
2049        mut omitted_candidate_count,
2050        mut considered_candidate_fingerprints,
2051        root_evidence_ids,
2052        subject,
2053        layout,
2054    } = input;
2055    if considered_candidate_fingerprints.len() > MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
2056        || candidates.iter().any(|candidate| {
2057            !considered_candidate_fingerprints.contains(&discovery_seed_fingerprint(candidate))
2058        })
2059        || u64::try_from(
2060            considered_candidate_fingerprints
2061                .len()
2062                .saturating_sub(candidates.len()),
2063        ) != Ok(omitted_candidate_count)
2064    {
2065        return Err(WordPressDiscoveryExecutionError::EvidenceModel);
2066    }
2067    if candidates.is_empty() {
2068        return Ok(WordPressDiscoveryExecution {
2069            audit: WebAssessmentWordPressDiscoveryAudit {
2070                seed_count: 0,
2071                candidate_count: 0,
2072                candidate_limit_reached: omitted_candidate_count > 0,
2073                omitted_candidate_count,
2074                attempted_request_count: 0,
2075                completed_response_count: 0,
2076                committed_response_count: 0,
2077                response_bytes: 0,
2078                sources: Vec::new(),
2079                layout,
2080                page_scope: None,
2081                supplied_session_pages: None,
2082            },
2083            signals: Vec::new(),
2084            stop: WordPressDiscoveryStop::Complete,
2085        });
2086    }
2087    if root_evidence_ids.is_empty()
2088        || root_evidence_ids.iter().any(|id| {
2089            authority
2090                .knowledge()
2091                .evidence(id)
2092                .is_none_or(|evidence| evidence.subject() != &subject)
2093        })
2094    {
2095        return Err(WordPressDiscoveryExecutionError::RootEvidenceUnavailable);
2096    }
2097    candidates.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
2098    candidates.dedup_by(|left, right| {
2099        discovery_seed_fingerprint(left) == discovery_seed_fingerprint(right)
2100    });
2101    if candidates.len() > MAX_WORDPRESS_DISCOVERY_CANDIDATES {
2102        let original_count = candidates.len();
2103        retain_pending_request_quota_candidates(
2104            &mut candidates,
2105            MAX_WORDPRESS_DISCOVERY_CANDIDATES,
2106            usize::from(MAX_WORDPRESS_DISCOVERY_REST_REQUESTS),
2107            usize::from(MAX_WORDPRESS_DISCOVERY_THEME_REQUESTS),
2108            usize::from(MAX_WORDPRESS_DISCOVERY_PLUGIN_REQUESTS),
2109        );
2110        let omitted = original_count.saturating_sub(candidates.len());
2111        omitted_candidate_count = omitted_candidate_count
2112            .checked_add(
2113                u64::try_from(omitted)
2114                    .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?,
2115            )
2116            .ok_or(WordPressDiscoveryExecutionError::EvidenceModel)?;
2117    }
2118    let seed_count = u8::try_from(candidates.len()).unwrap_or(u8::MAX);
2119    let mut sources = Vec::new();
2120    let mut pending_evidence = Vec::new();
2121    let mut signals = Vec::new();
2122    let mut rest_requests = 0_u8;
2123    let mut theme_requests = 0_u8;
2124    let mut plugin_requests = 0_u8;
2125    let mut attempted = 0_u8;
2126    let mut completed = 0_u8;
2127    let mut response_bytes = 0_u64;
2128    let mut stopped = WordPressDiscoveryStop::Complete;
2129    let mut throttled = false;
2130    let mut index = 0_usize;
2131    let evidence_reliability = authority.requests().policy().reliability().min(
2132        ConfidenceScore::from_percent(70)
2133            .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?,
2134    );
2135
2136    while index < candidates.len() {
2137        let candidate = candidates[index].clone();
2138        index += 1;
2139        if throttled {
2140            sources.push(empty_source(
2141                &candidate,
2142                WordPressDiscoverySourceOutcome::NotAttemptedAfterThrottle,
2143                false,
2144                0,
2145            ));
2146            continue;
2147        }
2148        if let Some(outcome) = candidate.preflight_outcome() {
2149            sources.push(empty_source(&candidate, outcome, false, 0));
2150            continue;
2151        }
2152        if !candidate.dispatch_contract_is_valid() {
2153            return Err(WordPressDiscoveryExecutionError::EvidenceModel);
2154        }
2155        let kind_count = match candidate.kind() {
2156            WordPressDiscoverySourceKind::RestIndex => &mut rest_requests,
2157            WordPressDiscoverySourceKind::ThemeStylesheet => &mut theme_requests,
2158            WordPressDiscoverySourceKind::PluginReadme => &mut plugin_requests,
2159        };
2160        let kind_limit = match candidate.kind() {
2161            WordPressDiscoverySourceKind::RestIndex => MAX_WORDPRESS_DISCOVERY_REST_REQUESTS,
2162            WordPressDiscoverySourceKind::ThemeStylesheet => MAX_WORDPRESS_DISCOVERY_THEME_REQUESTS,
2163            WordPressDiscoverySourceKind::PluginReadme => MAX_WORDPRESS_DISCOVERY_PLUGIN_REQUESTS,
2164        };
2165        if attempted >= MAX_WORDPRESS_DISCOVERY_REQUESTS || *kind_count >= kind_limit {
2166            sources.push(empty_source(
2167                &candidate,
2168                WordPressDiscoverySourceOutcome::NotSelectedByLimit,
2169                false,
2170                0,
2171            ));
2172            continue;
2173        }
2174        if authority.cancellation().is_cancelled() {
2175            sources.push(empty_source(
2176                &candidate,
2177                WordPressDiscoverySourceOutcome::Cancelled,
2178                false,
2179                0,
2180            ));
2181            stopped = WordPressDiscoveryStop::Cancelled;
2182            break;
2183        }
2184        if deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
2185            sources.push(empty_source(
2186                &candidate,
2187                WordPressDiscoverySourceOutcome::DeadlineExceeded,
2188                false,
2189                0,
2190            ));
2191            stopped = WordPressDiscoveryStop::DeadlineExceeded;
2192            break;
2193        }
2194        if response_bytes >= MAX_WORDPRESS_DISCOVERY_RESPONSE_BYTES {
2195            sources.push(empty_source(
2196                &candidate,
2197                WordPressDiscoverySourceOutcome::BudgetExhausted,
2198                false,
2199                0,
2200            ));
2201            stopped = WordPressDiscoveryStop::DiscoveryResponseLimit;
2202            break;
2203        }
2204
2205        let per_source_limit = match candidate.kind() {
2206            WordPressDiscoverySourceKind::RestIndex => MAX_WORDPRESS_DISCOVERY_REST_BYTES,
2207            WordPressDiscoverySourceKind::ThemeStylesheet
2208            | WordPressDiscoverySourceKind::PluginReadme => MAX_WORDPRESS_DISCOVERY_STATIC_BYTES,
2209        };
2210        let remaining = MAX_WORDPRESS_DISCOVERY_RESPONSE_BYTES.saturating_sub(response_bytes);
2211        let response_limit = u64::try_from(per_source_limit)
2212            .unwrap_or(u64::MAX)
2213            .min(remaining);
2214        let source_evidence_ids = candidate.source_evidence_ids(&root_evidence_ids);
2215        let request_descriptor = candidate.request_descriptor(source_evidence_ids.len())?;
2216        let before = authority.request_accounting().snapshot();
2217        let request = authority
2218            .requests()
2219            .collect_anonymous_wordpress_get_for_runtime(
2220                WORDPRESS_DISCOVERY_ACTION_ID,
2221                DecisionExecutionStage::Passive,
2222                Some(DecisionActionOrigin::Planned),
2223                DecisionExecutionLimits::new().with_max_response_body_bytes(response_limit),
2224                &request_descriptor,
2225            );
2226        let collected =
2227            await_bounded_request(request, authority.cancellation_token(), deadline).await;
2228        let after = authority.request_accounting().snapshot();
2229        let request_delta = after
2230            .total_requests()
2231            .saturating_sub(before.total_requests());
2232        if request_delta > 1 {
2233            return Err(WordPressDiscoveryExecutionError::EvidenceModel);
2234        }
2235        let request_attempted = request_delta == 1;
2236        let response_delta = after
2237            .response_bytes()
2238            .saturating_sub(before.response_bytes());
2239        attempted = attempted.saturating_add(u8::try_from(request_delta).unwrap_or(u8::MAX));
2240        *kind_count = (*kind_count).saturating_add(u8::try_from(request_delta).unwrap_or(u8::MAX));
2241        response_bytes = response_bytes.saturating_add(response_delta);
2242
2243        let response = match collected {
2244            BoundedRequest::Cancelled => {
2245                sources.push(empty_source(
2246                    &candidate,
2247                    WordPressDiscoverySourceOutcome::Cancelled,
2248                    request_attempted,
2249                    response_delta,
2250                ));
2251                stopped = WordPressDiscoveryStop::Cancelled;
2252                break;
2253            },
2254            BoundedRequest::DeadlineExceeded => {
2255                sources.push(empty_source(
2256                    &candidate,
2257                    WordPressDiscoverySourceOutcome::DeadlineExceeded,
2258                    request_attempted,
2259                    response_delta,
2260                ));
2261                stopped = WordPressDiscoveryStop::DeadlineExceeded;
2262                break;
2263            },
2264            BoundedRequest::Completed(result) => match *result {
2265                Err(HttpRequestBrokerError::RuntimeLimit(limit)) => {
2266                    sources.push(empty_source(
2267                        &candidate,
2268                        WordPressDiscoverySourceOutcome::BudgetExhausted,
2269                        request_attempted,
2270                        response_delta,
2271                    ));
2272                    stopped = WordPressDiscoveryStop::RuntimeLimit(limit.dimension());
2273                    break;
2274                },
2275                Err(HttpRequestBrokerError::Http(_)) => {
2276                    sources.push(empty_source(
2277                        &candidate,
2278                        WordPressDiscoverySourceOutcome::RequestFailed,
2279                        request_attempted,
2280                        response_delta,
2281                    ));
2282                    continue;
2283                },
2284                Ok(response) => response,
2285            },
2286        };
2287        completed = completed.saturating_add(1);
2288        let (outcome, namespaces, theme, plugin) = classify_response(&candidate, &response);
2289        let evidence = response_evidence(
2290            &subject,
2291            source_evidence_ids,
2292            candidate.kind(),
2293            outcome,
2294            response.body(),
2295            sources.len(),
2296            evidence_reliability,
2297        )?;
2298        let evidence_id = evidence.id().clone();
2299        pending_evidence.push(evidence);
2300
2301        if let (Some(component), Some(metadata)) = (candidate.component_identity(), theme.as_ref())
2302        {
2303            if let Some(version) = metadata.version() {
2304                let signal = WordPressComponentSignal::theme_stylesheet_declaration(
2305                    component.slug(),
2306                    version,
2307                )
2308                .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?;
2309                if !signals.contains(&signal) {
2310                    signals.push(signal);
2311                }
2312            }
2313            if candidate.parent_depth() == 0 {
2314                if let Some(template) = metadata.template() {
2315                    if let Some(parent) =
2316                        parent_theme_candidate(&candidate, template, evidence_id.clone())
2317                    {
2318                        let fingerprint = discovery_seed_fingerprint(&parent);
2319                        let pending_independent = candidates[index..]
2320                            .iter()
2321                            .position(|existing| {
2322                                discovery_seed_fingerprint(existing) == fingerprint
2323                            })
2324                            .map(|offset| index + offset);
2325                        let already_attempted = candidates[..index]
2326                            .iter()
2327                            .any(|existing| discovery_seed_fingerprint(existing) == fingerprint);
2328                        // A seed independently retained from the selected entry keeps that
2329                        // association. The child's Template declaration remains in its own
2330                        // metadata; rewriting a seed or committed source would misstate the
2331                        // request provenance used by the broker. Promote a matching pending
2332                        // seed so corroborating evidence cannot push the parent past the
2333                        // bounded theme quota.
2334                        if let Some(pending_index) = pending_independent {
2335                            let pending = candidates.remove(pending_index);
2336                            candidates.insert(index, pending);
2337                        } else if !already_attempted {
2338                            if !considered_candidate_fingerprints.contains(&fingerprint) {
2339                                if considered_candidate_fingerprints.len()
2340                                    == MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
2341                                {
2342                                    return Err(
2343                                        WordPressDiscoveryExecutionError::CandidateIdentityLimitExceeded,
2344                                    );
2345                                }
2346                                considered_candidate_fingerprints.insert(fingerprint);
2347                            }
2348                            let mut pending = candidates.split_off(index);
2349                            pending.push(parent);
2350                            retain_pending_request_quota_candidates(
2351                                &mut pending,
2352                                MAX_WORDPRESS_DISCOVERY_CANDIDATES.saturating_sub(index),
2353                                usize::from(
2354                                    MAX_WORDPRESS_DISCOVERY_REST_REQUESTS
2355                                        .saturating_sub(rest_requests),
2356                                ),
2357                                usize::from(
2358                                    MAX_WORDPRESS_DISCOVERY_THEME_REQUESTS
2359                                        .saturating_sub(theme_requests),
2360                                ),
2361                                usize::from(
2362                                    MAX_WORDPRESS_DISCOVERY_PLUGIN_REQUESTS
2363                                        .saturating_sub(plugin_requests),
2364                                ),
2365                            );
2366                            candidates.extend(pending);
2367                            omitted_candidate_count = u64::try_from(
2368                                considered_candidate_fingerprints
2369                                    .len()
2370                                    .saturating_sub(candidates.len()),
2371                            )
2372                            .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?;
2373                        }
2374                    }
2375                }
2376            }
2377        }
2378        throttled = outcome == WordPressDiscoverySourceOutcome::RateLimited;
2379        sources.push(WordPressDiscoverySourceAudit {
2380            kind: candidate.kind(),
2381            component: candidate.component_identity().cloned(),
2382            parent_depth: candidate.parent_depth(),
2383            outcome,
2384            request_attempted,
2385            response_bytes: response_delta,
2386            evidence_ids: vec![evidence_id],
2387            namespaces,
2388            theme,
2389            plugin,
2390            association: candidate.association(),
2391            resource_reference: candidate.resource_reference(),
2392            role_reference: candidate.role_reference(),
2393            source_page_references: candidate.source_page_references().to_vec(),
2394            source_supplied_session_page_references: candidate
2395                .source_supplied_session_page_references()
2396                .to_vec(),
2397        });
2398        if response_bytes >= MAX_WORDPRESS_DISCOVERY_RESPONSE_BYTES {
2399            stopped = WordPressDiscoveryStop::DiscoveryResponseLimit;
2400            break;
2401        }
2402    }
2403
2404    let unattempted_outcome = match stopped {
2405        WordPressDiscoveryStop::Complete => None,
2406        WordPressDiscoveryStop::Cancelled => Some(WordPressDiscoverySourceOutcome::Cancelled),
2407        WordPressDiscoveryStop::DeadlineExceeded => {
2408            Some(WordPressDiscoverySourceOutcome::DeadlineExceeded)
2409        },
2410        WordPressDiscoveryStop::RuntimeLimit(_)
2411        | WordPressDiscoveryStop::DiscoveryResponseLimit => {
2412            Some(WordPressDiscoverySourceOutcome::BudgetExhausted)
2413        },
2414    };
2415    if let Some(outcome) = unattempted_outcome {
2416        for candidate in &candidates[index..] {
2417            sources.push(empty_source(candidate, outcome, false, 0));
2418        }
2419    }
2420
2421    if !pending_evidence.is_empty() {
2422        authority
2423            .knowledge()
2424            .insert_evidence_batch(pending_evidence)
2425            .map_err(|_| WordPressDiscoveryExecutionError::EvidenceCommit)?;
2426    }
2427    let committed = sources
2428        .iter()
2429        .try_fold(0_usize, |count, source| {
2430            count.checked_add(source.evidence_ids.len())
2431        })
2432        .and_then(|count| u8::try_from(count).ok())
2433        .unwrap_or(u8::MAX);
2434    Ok(WordPressDiscoveryExecution {
2435        audit: WebAssessmentWordPressDiscoveryAudit {
2436            seed_count,
2437            candidate_count: u8::try_from(candidates.len()).unwrap_or(u8::MAX),
2438            candidate_limit_reached: omitted_candidate_count > 0,
2439            omitted_candidate_count,
2440            attempted_request_count: attempted,
2441            completed_response_count: completed,
2442            committed_response_count: committed,
2443            response_bytes,
2444            sources,
2445            layout,
2446            page_scope: None,
2447            supplied_session_pages: None,
2448        },
2449        signals,
2450        stop: stopped,
2451    })
2452}
2453
2454fn retain_pending_request_quota_candidates(
2455    candidates: &mut Vec<WordPressDiscoverySeed>,
2456    capacity: usize,
2457    rest_limit: usize,
2458    theme_limit: usize,
2459    plugin_limit: usize,
2460) {
2461    candidates.sort_by(|left, right| {
2462        let left_parent_priority = u8::from(
2463            left.kind() != WordPressDiscoverySourceKind::ThemeStylesheet
2464                || left.parent_depth() == 0,
2465        );
2466        let right_parent_priority = u8::from(
2467            right.kind() != WordPressDiscoverySourceKind::ThemeStylesheet
2468                || right.parent_depth() == 0,
2469        );
2470        left.sort_key()
2471            .0
2472            .cmp(&right.sort_key().0)
2473            .then_with(|| left_parent_priority.cmp(&right_parent_priority))
2474            .then_with(|| left.sort_key().cmp(&right.sort_key()))
2475    });
2476    if candidates.len() <= capacity {
2477        return;
2478    }
2479
2480    let mut selected = vec![false; candidates.len()];
2481    let mut selected_count = 0_usize;
2482    for (kind, limit) in [
2483        (WordPressDiscoverySourceKind::RestIndex, rest_limit),
2484        (WordPressDiscoverySourceKind::ThemeStylesheet, theme_limit),
2485        (WordPressDiscoverySourceKind::PluginReadme, plugin_limit),
2486    ] {
2487        let remaining_capacity = capacity.saturating_sub(selected_count);
2488        for (index, _) in candidates
2489            .iter()
2490            .enumerate()
2491            .filter(|(_, candidate)| {
2492                candidate.kind() == kind && candidate.preflight_outcome().is_none()
2493            })
2494            .take(limit.min(remaining_capacity))
2495        {
2496            selected[index] = true;
2497            selected_count += 1;
2498        }
2499    }
2500    for retained in &mut selected {
2501        if selected_count == capacity {
2502            break;
2503        }
2504        if !*retained {
2505            *retained = true;
2506            selected_count += 1;
2507        }
2508    }
2509    let mut index = 0_usize;
2510    candidates.retain(|_| {
2511        let retain = selected[index];
2512        index += 1;
2513        retain
2514    });
2515}
2516
2517enum BoundedRequest {
2518    Completed(Box<Result<crate::http_evidence::CollectedHttpResponse, HttpRequestBrokerError>>),
2519    Cancelled,
2520    DeadlineExceeded,
2521}
2522
2523async fn await_bounded_request(
2524    request: impl std::future::Future<
2525        Output = Result<crate::http_evidence::CollectedHttpResponse, HttpRequestBrokerError>,
2526    >,
2527    cancellation: CancellationToken,
2528    deadline: Option<tokio::time::Instant>,
2529) -> BoundedRequest {
2530    match deadline {
2531        Some(deadline) => {
2532            tokio::select! {
2533                _ = cancellation.cancelled() => BoundedRequest::Cancelled,
2534                result = tokio::time::timeout_at(deadline, request) => match result {
2535                    Ok(result) => BoundedRequest::Completed(Box::new(result)),
2536                    Err(_) => BoundedRequest::DeadlineExceeded,
2537                },
2538            }
2539        },
2540        None => {
2541            tokio::select! {
2542                _ = cancellation.cancelled() => BoundedRequest::Cancelled,
2543                result = request => BoundedRequest::Completed(Box::new(result)),
2544            }
2545        },
2546    }
2547}
2548
2549fn empty_source(
2550    seed: &WordPressDiscoverySeed,
2551    outcome: WordPressDiscoverySourceOutcome,
2552    request_attempted: bool,
2553    response_bytes: u64,
2554) -> WordPressDiscoverySourceAudit {
2555    WordPressDiscoverySourceAudit {
2556        kind: seed.kind(),
2557        component: seed.component_identity().cloned(),
2558        parent_depth: seed.parent_depth(),
2559        outcome,
2560        request_attempted,
2561        response_bytes,
2562        evidence_ids: Vec::new(),
2563        namespaces: Vec::new(),
2564        theme: None,
2565        plugin: None,
2566        association: seed.association(),
2567        resource_reference: seed.resource_reference(),
2568        role_reference: seed.role_reference(),
2569        source_page_references: seed.source_page_references().to_vec(),
2570        source_supplied_session_page_references: seed
2571            .source_supplied_session_page_references()
2572            .to_vec(),
2573    }
2574}
2575
2576fn response_evidence(
2577    subject: &EntityId,
2578    parents: &[EvidenceId],
2579    kind: WordPressDiscoverySourceKind,
2580    outcome: WordPressDiscoverySourceOutcome,
2581    body: &[u8],
2582    sequence: usize,
2583    reliability: ConfidenceScore,
2584) -> Result<Evidence, WordPressDiscoveryExecutionError> {
2585    let source = EvidenceSource::new(WORDPRESS_DISCOVERY_EVIDENCE_COMPONENT, kind.as_str())
2586        .and_then(|source| {
2587            source.with_correlation_id(format!("wordpress-discovery-{}", sequence + 1))
2588        })
2589        .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?;
2590    let predicate = KnowledgePredicate::new(
2591        WORDPRESS_DISCOVERY_EVIDENCE_NAMESPACE,
2592        format!("{}-response", kind.as_str().replace('_', "-")),
2593    )
2594    .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?;
2595    let derivation = EvidenceDerivation::new(
2596        parents.iter().cloned(),
2597        DerivationAlgorithm::new("wordpress-public-metadata", 1)
2598            .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?,
2599    )
2600    .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?;
2601    Ok(Evidence::new(
2602        subject.clone(),
2603        EvidenceKind::Custom("wordpress-discovery".to_owned()),
2604        predicate,
2605        EvidenceValue::Text(format!(
2606            "{}:sha256:{:x}",
2607            outcome.as_str(),
2608            Sha256::digest(body)
2609        )),
2610        source,
2611        reliability,
2612    )
2613    .derived_from(derivation))
2614}
2615
2616fn parent_theme_candidate(
2617    current: &WordPressDiscoverySeed,
2618    template: &str,
2619    source_evidence_id: EvidenceId,
2620) -> Option<WordPressDiscoverySeed> {
2621    let identity = WordPressComponentIdentity::new(WordPressComponentKind::Theme, template).ok()?;
2622    if current.kind() != WordPressDiscoverySourceKind::ThemeStylesheet
2623        || current.parent_depth() != 0
2624        || current
2625            .component_identity()
2626            .is_some_and(|component| component.slug() == template)
2627    {
2628        return None;
2629    }
2630    let url = current
2631        .role_base_url()
2632        .join(&format!("{template}/style.css"))
2633        .ok()?;
2634    let mut parent = WordPressDiscoverySeed::admitted_component(
2635        url,
2636        current.application_url().clone(),
2637        current.role_base_url().clone(),
2638        WordPressDiscoveryAssociation::SameThemeBaseParent,
2639        identity,
2640        1,
2641    )
2642    .with_source_evidence(source_evidence_id);
2643    for source_page_reference in current.source_page_references() {
2644        parent = parent.with_source_page_reference(source_page_reference.clone());
2645    }
2646    Some(parent)
2647}
2648
2649fn classify_response(
2650    candidate: &WordPressDiscoverySeed,
2651    response: &crate::http_evidence::CollectedHttpResponse,
2652) -> (
2653    WordPressDiscoverySourceOutcome,
2654    Vec<String>,
2655    Option<WordPressThemeDiscoveryMetadata>,
2656    Option<WordPressPluginDiscoveryMetadata>,
2657) {
2658    let status = response.status();
2659    if status == 429 {
2660        return (
2661            WordPressDiscoverySourceOutcome::RateLimited,
2662            Vec::new(),
2663            None,
2664            None,
2665        );
2666    }
2667    if matches!(status, 401 | 403) {
2668        return (
2669            WordPressDiscoverySourceOutcome::Unauthorized,
2670            Vec::new(),
2671            None,
2672            None,
2673        );
2674    }
2675    if status == 404 {
2676        return (
2677            WordPressDiscoverySourceOutcome::NotFound,
2678            Vec::new(),
2679            None,
2680            None,
2681        );
2682    }
2683    if (300..400).contains(&status) {
2684        return (
2685            WordPressDiscoverySourceOutcome::RedirectObserved,
2686            Vec::new(),
2687            None,
2688            None,
2689        );
2690    }
2691    if status == 206 || response.body_truncated() {
2692        return (
2693            WordPressDiscoverySourceOutcome::Truncated,
2694            Vec::new(),
2695            None,
2696            None,
2697        );
2698    }
2699    if status != 200 {
2700        return (
2701            WordPressDiscoverySourceOutcome::UnsupportedContent,
2702            Vec::new(),
2703            None,
2704            None,
2705        );
2706    }
2707    // Reaching the exact retention ceiling is not evidence that the response
2708    // ended there. Parse successful metadata only after the broker observed
2709    // EOF; otherwise a valid-looking retained prefix could mint metadata from
2710    // a longer response.
2711    if !response.body_complete() {
2712        return (
2713            WordPressDiscoverySourceOutcome::Truncated,
2714            Vec::new(),
2715            None,
2716            None,
2717        );
2718    }
2719    match candidate.kind() {
2720        WordPressDiscoverySourceKind::RestIndex => match parse_rest_namespaces(response) {
2721            Ok(namespaces) if namespaces.is_empty() => (
2722                WordPressDiscoverySourceOutcome::NoMetadata,
2723                namespaces,
2724                None,
2725                None,
2726            ),
2727            Ok(namespaces) => (
2728                WordPressDiscoverySourceOutcome::Observed,
2729                namespaces,
2730                None,
2731                None,
2732            ),
2733            Err(ParseMetadataError::Unsupported) => (
2734                WordPressDiscoverySourceOutcome::UnsupportedContent,
2735                Vec::new(),
2736                None,
2737                None,
2738            ),
2739            Err(ParseMetadataError::Malformed) => (
2740                WordPressDiscoverySourceOutcome::Malformed,
2741                Vec::new(),
2742                None,
2743                None,
2744            ),
2745            Err(ParseMetadataError::Truncated) => (
2746                WordPressDiscoverySourceOutcome::Truncated,
2747                Vec::new(),
2748                None,
2749                None,
2750            ),
2751        },
2752        WordPressDiscoverySourceKind::ThemeStylesheet => {
2753            if response.normalized_media_type().as_deref() != Some("text/css") {
2754                return (
2755                    WordPressDiscoverySourceOutcome::UnsupportedContent,
2756                    Vec::new(),
2757                    None,
2758                    None,
2759                );
2760            }
2761            match parse_theme_metadata(response.body()) {
2762                Ok(Some(theme)) => (
2763                    WordPressDiscoverySourceOutcome::Observed,
2764                    Vec::new(),
2765                    Some(theme),
2766                    None,
2767                ),
2768                Ok(None) => (
2769                    WordPressDiscoverySourceOutcome::NoMetadata,
2770                    Vec::new(),
2771                    None,
2772                    None,
2773                ),
2774                Err(error) => (
2775                    match error {
2776                        ParseMetadataError::Truncated => WordPressDiscoverySourceOutcome::Truncated,
2777                        ParseMetadataError::Unsupported => {
2778                            WordPressDiscoverySourceOutcome::UnsupportedContent
2779                        },
2780                        ParseMetadataError::Malformed => WordPressDiscoverySourceOutcome::Malformed,
2781                    },
2782                    Vec::new(),
2783                    None,
2784                    None,
2785                ),
2786            }
2787        },
2788        WordPressDiscoverySourceKind::PluginReadme => {
2789            if response.normalized_media_type().as_deref() != Some("text/plain") {
2790                return (
2791                    WordPressDiscoverySourceOutcome::UnsupportedContent,
2792                    Vec::new(),
2793                    None,
2794                    None,
2795                );
2796            }
2797            match parse_plugin_metadata(response.body()) {
2798                Ok(Some(plugin)) => (
2799                    WordPressDiscoverySourceOutcome::Observed,
2800                    Vec::new(),
2801                    None,
2802                    Some(plugin),
2803                ),
2804                Ok(None) => (
2805                    WordPressDiscoverySourceOutcome::NoMetadata,
2806                    Vec::new(),
2807                    None,
2808                    None,
2809                ),
2810                Err(error) => (
2811                    match error {
2812                        ParseMetadataError::Truncated => WordPressDiscoverySourceOutcome::Truncated,
2813                        ParseMetadataError::Unsupported => {
2814                            WordPressDiscoverySourceOutcome::UnsupportedContent
2815                        },
2816                        ParseMetadataError::Malformed => WordPressDiscoverySourceOutcome::Malformed,
2817                    },
2818                    Vec::new(),
2819                    None,
2820                    None,
2821                ),
2822            }
2823        },
2824    }
2825}
2826
2827#[derive(Debug, Eq, PartialEq)]
2828enum ParseMetadataError {
2829    Unsupported,
2830    Malformed,
2831    Truncated,
2832}
2833
2834#[derive(Deserialize)]
2835struct RestIndexWire {
2836    #[serde(default)]
2837    namespaces: Vec<String>,
2838}
2839
2840fn parse_rest_namespaces(
2841    response: &crate::http_evidence::CollectedHttpResponse,
2842) -> Result<Vec<String>, ParseMetadataError> {
2843    if !response.has_json_compatible_media_type() {
2844        return Err(ParseMetadataError::Unsupported);
2845    }
2846    parse_rest_namespaces_body(response.body())
2847}
2848
2849fn parse_rest_namespaces_body(body: &[u8]) -> Result<Vec<String>, ParseMetadataError> {
2850    validate_rest_json_shape(body)?;
2851    let wire: RestIndexWire =
2852        serde_json::from_slice(body).map_err(|_| ParseMetadataError::Malformed)?;
2853    if wire.namespaces.len() > MAX_REST_NAMESPACES
2854        || wire.namespaces.iter().any(|namespace| {
2855            namespace.is_empty()
2856                || namespace.len() > MAX_REST_NAMESPACE_BYTES
2857                || !namespace.is_ascii()
2858                || namespace.chars().any(char::is_control)
2859        })
2860    {
2861        return Err(ParseMetadataError::Malformed);
2862    }
2863    let mut namespaces = wire.namespaces;
2864    namespaces.sort();
2865    namespaces.dedup();
2866    Ok(namespaces)
2867}
2868
2869#[cfg(fuzzing)]
2870pub(super) fn fuzz_check_wordpress_discovery_parser(scenario: u8, data: &[u8]) {
2871    match scenario % 8 {
2872        0 | 1 => {},
2873        2 => {
2874            let first = parse_rest_namespaces_body(data);
2875            let repeated = parse_rest_namespaces_body(data);
2876            assert_eq!(
2877                first, repeated,
2878                "REST metadata parsing must be deterministic"
2879            );
2880            if let Ok(namespaces) = first {
2881                assert!(namespaces.len() <= MAX_REST_NAMESPACES);
2882                assert!(namespaces.windows(2).all(|pair| pair[0] < pair[1]));
2883                assert!(namespaces.iter().all(|namespace| {
2884                    !namespace.is_empty()
2885                        && namespace.len() <= MAX_REST_NAMESPACE_BYTES
2886                        && namespace.is_ascii()
2887                        && !namespace.chars().any(char::is_control)
2888                }));
2889            }
2890        },
2891        3 => fuzz_check_theme_metadata(data),
2892        4 => fuzz_check_plugin_metadata(data),
2893        5 => {
2894            let mut clipped = b"/*\nTheme Name: Fuzz Theme\nVersion: 1.2.3\n".to_vec();
2895            clipped.resize(MAX_METADATA_HEADER_BYTES + 1, b'x');
2896            assert_eq!(
2897                parse_theme_metadata(&clipped),
2898                Err(ParseMetadataError::Truncated)
2899            );
2900        },
2901        6 => {
2902            let mut clipped = b"=== Fuzz Plugin ===\nStable tag: 1.2.3\n".to_vec();
2903            clipped.resize(MAX_METADATA_HEADER_BYTES + 1, b'x');
2904            assert_eq!(
2905                parse_plugin_metadata(&clipped),
2906                Err(ParseMetadataError::Truncated)
2907            );
2908        },
2909        _ => {
2910            let prefix = b"=== Fuzz Plugin ===\nStable tag: 1.2.3\n\n";
2911            let mut clipped = prefix.to_vec();
2912            clipped.resize(MAX_METADATA_HEADER_BYTES - 1, b'x');
2913            clipped.extend_from_slice("é".as_bytes());
2914            let metadata = parse_plugin_metadata(&clipped)
2915                .expect("complete header before clipped UTF-8 must parse")
2916                .expect("fixed plugin header must retain metadata");
2917            assert_eq!(metadata.name(), Some("Fuzz Plugin"));
2918            assert_eq!(metadata.stable_tag(), Some("1.2.3"));
2919        },
2920    }
2921}
2922
2923#[cfg(fuzzing)]
2924fn fuzz_check_theme_metadata(data: &[u8]) {
2925    let first = parse_theme_metadata(data);
2926    let repeated = parse_theme_metadata(data);
2927    assert_eq!(
2928        first, repeated,
2929        "theme metadata parsing must be deterministic"
2930    );
2931    if let Ok(Some(metadata)) = first {
2932        for value in [
2933            metadata.name(),
2934            metadata.template(),
2935            metadata.requires_wordpress(),
2936            metadata.requires_php(),
2937            metadata.tested_up_to(),
2938        ]
2939        .into_iter()
2940        .flatten()
2941        {
2942            assert!(value.len() <= MAX_METADATA_VALUE_BYTES);
2943            assert!(!value.chars().any(char::is_control));
2944        }
2945        assert!(metadata
2946            .version()
2947            .is_none_or(|version| version.len() <= MAX_DISCOVERED_VERSION_BYTES));
2948    }
2949}
2950
2951#[cfg(fuzzing)]
2952fn fuzz_check_plugin_metadata(data: &[u8]) {
2953    let first = parse_plugin_metadata(data);
2954    let repeated = parse_plugin_metadata(data);
2955    assert_eq!(
2956        first, repeated,
2957        "plugin metadata parsing must be deterministic"
2958    );
2959    if let Ok(Some(metadata)) = first {
2960        for value in [
2961            metadata.name(),
2962            metadata.stable_tag(),
2963            metadata.requires_wordpress(),
2964            metadata.requires_php(),
2965            metadata.tested_up_to(),
2966        ]
2967        .into_iter()
2968        .flatten()
2969        {
2970            assert!(value.len() <= MAX_METADATA_VALUE_BYTES);
2971            assert!(!value.chars().any(char::is_control));
2972        }
2973    }
2974}
2975
2976fn validate_rest_json_shape(bytes: &[u8]) -> Result<(), ParseMetadataError> {
2977    let mut nodes = 0_usize;
2978    let mut decoder = serde_json::Deserializer::from_slice(bytes);
2979    RestJsonSeed {
2980        depth: 0,
2981        nodes: &mut nodes,
2982    }
2983    .deserialize(&mut decoder)
2984    .map_err(|error| {
2985        if error.to_string().contains("wordpress-rest-json-limit") {
2986            ParseMetadataError::Unsupported
2987        } else {
2988            ParseMetadataError::Malformed
2989        }
2990    })?;
2991    decoder.end().map_err(|_| ParseMetadataError::Malformed)
2992}
2993
2994struct RestJsonSeed<'a> {
2995    depth: usize,
2996    nodes: &'a mut usize,
2997}
2998
2999impl<'de> DeserializeSeed<'de> for RestJsonSeed<'_> {
3000    type Value = ();
3001
3002    fn deserialize<D: de::Deserializer<'de>>(self, decoder: D) -> Result<(), D::Error> {
3003        if self.depth > MAX_REST_JSON_DEPTH || *self.nodes == MAX_REST_JSON_NODES {
3004            return Err(de::Error::custom("wordpress-rest-json-limit"));
3005        }
3006        *self.nodes += 1;
3007        decoder.deserialize_any(RestJsonVisitor {
3008            depth: self.depth,
3009            nodes: self.nodes,
3010        })
3011    }
3012}
3013
3014struct RestJsonVisitor<'a> {
3015    depth: usize,
3016    nodes: &'a mut usize,
3017}
3018
3019impl<'de> Visitor<'de> for RestJsonVisitor<'_> {
3020    type Value = ();
3021
3022    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3023        formatter.write_str("bounded WordPress REST JSON without duplicate object keys")
3024    }
3025
3026    fn visit_bool<E: de::Error>(self, _: bool) -> Result<(), E> {
3027        Ok(())
3028    }
3029
3030    fn visit_i64<E: de::Error>(self, _: i64) -> Result<(), E> {
3031        Ok(())
3032    }
3033
3034    fn visit_u64<E: de::Error>(self, _: u64) -> Result<(), E> {
3035        Ok(())
3036    }
3037
3038    fn visit_f64<E: de::Error>(self, _: f64) -> Result<(), E> {
3039        Ok(())
3040    }
3041
3042    fn visit_borrowed_str<E: de::Error>(self, value: &'de str) -> Result<(), E> {
3043        validate_rest_json_string::<E>(value)
3044    }
3045
3046    fn visit_str<E: de::Error>(self, value: &str) -> Result<(), E> {
3047        validate_rest_json_string::<E>(value)
3048    }
3049
3050    fn visit_string<E: de::Error>(self, value: String) -> Result<(), E> {
3051        validate_rest_json_string::<E>(&value)
3052    }
3053
3054    fn visit_none<E: de::Error>(self) -> Result<(), E> {
3055        Ok(())
3056    }
3057
3058    fn visit_unit<E: de::Error>(self) -> Result<(), E> {
3059        Ok(())
3060    }
3061
3062    fn visit_some<D: de::Deserializer<'de>>(self, decoder: D) -> Result<(), D::Error> {
3063        RestJsonSeed {
3064            depth: self.depth + 1,
3065            nodes: self.nodes,
3066        }
3067        .deserialize(decoder)
3068    }
3069
3070    fn visit_seq<A: SeqAccess<'de>>(self, mut values: A) -> Result<(), A::Error> {
3071        let mut length = 0_usize;
3072        loop {
3073            if length == MAX_REST_JSON_COLLECTION_LENGTH {
3074                if values
3075                    .next_element_seed(RestJsonSeed {
3076                        depth: self.depth + 1,
3077                        nodes: &mut *self.nodes,
3078                    })?
3079                    .is_some()
3080                {
3081                    return Err(de::Error::custom("wordpress-rest-json-limit"));
3082                }
3083                break;
3084            }
3085            let Some(()) = values.next_element_seed(RestJsonSeed {
3086                depth: self.depth + 1,
3087                nodes: &mut *self.nodes,
3088            })?
3089            else {
3090                break;
3091            };
3092            length += 1;
3093        }
3094        Ok(())
3095    }
3096
3097    fn visit_map<A: MapAccess<'de>>(self, mut fields: A) -> Result<(), A::Error> {
3098        let mut keys = BTreeSet::new();
3099        loop {
3100            if keys.len() == MAX_REST_JSON_COLLECTION_LENGTH {
3101                if let Some(key) = fields.next_key_seed(RestJsonKeySeed)? {
3102                    if keys.contains(&key) {
3103                        return Err(de::Error::custom("wordpress-rest-duplicate-key"));
3104                    }
3105                    return Err(de::Error::custom("wordpress-rest-json-limit"));
3106                }
3107                break;
3108            }
3109            let Some(key) = fields.next_key_seed(RestJsonKeySeed)? else {
3110                break;
3111            };
3112            if !keys.insert(key) {
3113                return Err(de::Error::custom("wordpress-rest-duplicate-key"));
3114            }
3115            fields.next_value_seed(RestJsonSeed {
3116                depth: self.depth + 1,
3117                nodes: &mut *self.nodes,
3118            })?;
3119        }
3120        Ok(())
3121    }
3122}
3123
3124fn validate_rest_json_string<E: de::Error>(value: &str) -> Result<(), E> {
3125    if value.len() > MAX_REST_JSON_STRING_BYTES {
3126        Err(de::Error::custom("wordpress-rest-json-limit"))
3127    } else {
3128        Ok(())
3129    }
3130}
3131
3132struct RestJsonKeySeed;
3133
3134impl<'de> DeserializeSeed<'de> for RestJsonKeySeed {
3135    type Value = String;
3136
3137    fn deserialize<D: de::Deserializer<'de>>(self, decoder: D) -> Result<Self::Value, D::Error> {
3138        decoder.deserialize_string(RestJsonKeyVisitor)
3139    }
3140}
3141
3142struct RestJsonKeyVisitor;
3143
3144impl<'de> Visitor<'de> for RestJsonKeyVisitor {
3145    type Value = String;
3146
3147    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3148        formatter.write_str("a bounded WordPress REST JSON object key")
3149    }
3150
3151    fn visit_borrowed_str<E: de::Error>(self, value: &'de str) -> Result<Self::Value, E> {
3152        validate_rest_json_key(value)?;
3153        Ok(value.to_owned())
3154    }
3155
3156    fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
3157        validate_rest_json_key(value)?;
3158        Ok(value.to_owned())
3159    }
3160
3161    fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
3162        validate_rest_json_key(&value)?;
3163        Ok(value)
3164    }
3165}
3166
3167fn validate_rest_json_key<E: de::Error>(value: &str) -> Result<(), E> {
3168    if value.len() > MAX_REST_JSON_KEY_BYTES {
3169        Err(de::Error::custom("wordpress-rest-json-limit"))
3170    } else {
3171        Ok(())
3172    }
3173}
3174
3175fn parse_theme_metadata(
3176    body: &[u8],
3177) -> Result<Option<WordPressThemeDiscoveryMetadata>, ParseMetadataError> {
3178    let header = bounded_utf8_prefix(body)?;
3179    let text = header.text.trim_start_matches('\u{feff}').trim_start();
3180    let Some(text) = text.strip_prefix("/*") else {
3181        return Ok(None);
3182    };
3183    let Some(end) = text.find("*/") else {
3184        return Err(if header.clipped {
3185            ParseMetadataError::Truncated
3186        } else {
3187            ParseMetadataError::Malformed
3188        });
3189    };
3190    let fields = parse_header_fields(&text[..end])?;
3191    let Some(name) = field(&fields, "theme name") else {
3192        return Ok(None);
3193    };
3194    let metadata = WordPressThemeDiscoveryMetadata {
3195        name: Some(name),
3196        version: field(&fields, "version"),
3197        template: field(&fields, "template"),
3198        requires_wordpress: field(&fields, "requires at least"),
3199        requires_php: field(&fields, "requires php"),
3200        tested_up_to: field(&fields, "tested up to"),
3201    };
3202    Ok(Some(metadata))
3203}
3204
3205fn parse_plugin_metadata(
3206    body: &[u8],
3207) -> Result<Option<WordPressPluginDiscoveryMetadata>, ParseMetadataError> {
3208    let header = bounded_utf8_prefix(body)?;
3209    let mut lines = header.text.split_inclusive('\n');
3210    let Some(title_line) = lines.next() else {
3211        return Ok(None);
3212    };
3213    if header.clipped && !title_line.ends_with('\n') {
3214        return Err(ParseMetadataError::Truncated);
3215    }
3216    let title = title_line
3217        .trim_end_matches('\n')
3218        .trim_end_matches('\r')
3219        .trim();
3220    let name = title
3221        .strip_prefix("===")
3222        .and_then(|value| value.strip_suffix("==="))
3223        .map(str::trim)
3224        .filter(|value| !value.is_empty())
3225        .map(ToOwned::to_owned);
3226    let Some(name) = name else {
3227        return Ok(None);
3228    };
3229    if name.len() > MAX_METADATA_VALUE_BYTES || name.chars().any(char::is_control) {
3230        return Err(ParseMetadataError::Malformed);
3231    }
3232    let mut header_lines = String::new();
3233    let mut terminated = false;
3234    for raw_line in lines {
3235        let complete_line = raw_line.ends_with('\n') || !header.clipped;
3236        let line = raw_line.trim_end_matches('\n').trim_end_matches('\r');
3237        if line.trim().is_empty() || line.trim_start().starts_with("==") {
3238            terminated = true;
3239            break;
3240        }
3241        if !complete_line {
3242            return Err(ParseMetadataError::Truncated);
3243        }
3244        header_lines.push_str(line);
3245        header_lines.push('\n');
3246    }
3247    if header.clipped && !terminated {
3248        return Err(ParseMetadataError::Truncated);
3249    }
3250    let fields = parse_header_fields(&header_lines)?;
3251    let stable_tag =
3252        field(&fields, "stable tag").filter(|value| !value.eq_ignore_ascii_case("trunk"));
3253    Ok(Some(WordPressPluginDiscoveryMetadata {
3254        name: Some(name),
3255        stable_tag,
3256        requires_wordpress: field(&fields, "requires at least"),
3257        requires_php: field(&fields, "requires php"),
3258        tested_up_to: field(&fields, "tested up to"),
3259    }))
3260}
3261
3262struct BoundedUtf8Prefix<'a> {
3263    text: &'a str,
3264    clipped: bool,
3265}
3266
3267fn bounded_utf8_prefix(body: &[u8]) -> Result<BoundedUtf8Prefix<'_>, ParseMetadataError> {
3268    let end = body.len().min(MAX_METADATA_HEADER_BYTES);
3269    let clipped = body.len() > end;
3270    match std::str::from_utf8(&body[..end]) {
3271        Ok(text) => Ok(BoundedUtf8Prefix { text, clipped }),
3272        Err(error) if clipped && error.error_len().is_none() => {
3273            let text = std::str::from_utf8(&body[..error.valid_up_to()])
3274                .map_err(|_| ParseMetadataError::Malformed)?;
3275            Ok(BoundedUtf8Prefix { text, clipped })
3276        },
3277        Err(_) => Err(ParseMetadataError::Malformed),
3278    }
3279}
3280
3281fn parse_header_fields(
3282    input: &str,
3283) -> Result<std::collections::BTreeMap<String, String>, ParseMetadataError> {
3284    let mut fields = std::collections::BTreeMap::new();
3285    for raw_line in input.lines() {
3286        let line = raw_line
3287            .trim_end_matches('\r')
3288            .trim_start_matches(|character: char| {
3289                character.is_ascii_whitespace() || character == '*'
3290            })
3291            .trim();
3292        let Some((name, value)) = line.split_once(':') else {
3293            continue;
3294        };
3295        let name = name.trim().to_ascii_lowercase();
3296        if !matches!(
3297            name.as_str(),
3298            "theme name"
3299                | "version"
3300                | "template"
3301                | "requires at least"
3302                | "requires php"
3303                | "tested up to"
3304                | "stable tag"
3305        ) {
3306            continue;
3307        }
3308        let value = value.trim();
3309        if value.is_empty()
3310            || value.len() > MAX_METADATA_VALUE_BYTES
3311            || value.chars().any(char::is_control)
3312        {
3313            return Err(ParseMetadataError::Malformed);
3314        }
3315        if name == "version" && value.len() > MAX_DISCOVERED_VERSION_BYTES {
3316            return Err(ParseMetadataError::Unsupported);
3317        }
3318        if fields.insert(name, value.to_owned()).is_some() {
3319            return Err(ParseMetadataError::Malformed);
3320        }
3321    }
3322    Ok(fields)
3323}
3324
3325fn field(fields: &std::collections::BTreeMap<String, String>, name: &str) -> Option<String> {
3326    fields.get(name).cloned()
3327}
3328
3329#[cfg(test)]
3330mod tests {
3331    use super::*;
3332
3333    fn request_failed_source(
3334        kind: WordPressDiscoverySourceKind,
3335        ordinal: usize,
3336    ) -> WordPressDiscoverySourceAudit {
3337        let application = Url::parse("https://example.test/").unwrap();
3338        let seed = match kind {
3339            WordPressDiscoverySourceKind::RestIndex => WordPressDiscoverySeed::admitted_rest_index(
3340                if ordinal == 0 {
3341                    Url::parse("https://example.test/wp-json/").unwrap()
3342                } else {
3343                    Url::parse("https://example.test/?rest_route=/").unwrap()
3344                },
3345                application.clone(),
3346                application,
3347                WordPressDiscoveryAssociation::StructuredAdvertisement,
3348            ),
3349            WordPressDiscoverySourceKind::ThemeStylesheet => {
3350                let role = Url::parse("https://example.test/wp-content/themes/").unwrap();
3351                let component = WordPressComponentIdentity::new(
3352                    WordPressComponentKind::Theme,
3353                    format!("quota-theme-{ordinal}"),
3354                )
3355                .unwrap();
3356                WordPressDiscoverySeed::admitted_component(
3357                    role.join(&format!("{}/style.css", component.slug()))
3358                        .unwrap(),
3359                    application,
3360                    role,
3361                    WordPressDiscoveryAssociation::ObservedConventional,
3362                    component,
3363                    0,
3364                )
3365            },
3366            WordPressDiscoverySourceKind::PluginReadme => {
3367                let role = Url::parse("https://example.test/wp-content/plugins/").unwrap();
3368                let component = WordPressComponentIdentity::new(
3369                    WordPressComponentKind::Plugin,
3370                    format!("quota-plugin-{ordinal}"),
3371                )
3372                .unwrap();
3373                WordPressDiscoverySeed::admitted_component(
3374                    role.join(&format!("{}/readme.txt", component.slug()))
3375                        .unwrap(),
3376                    application,
3377                    role,
3378                    WordPressDiscoveryAssociation::ObservedConventional,
3379                    component,
3380                    0,
3381                )
3382            },
3383        };
3384        empty_source(
3385            &seed,
3386            WordPressDiscoverySourceOutcome::RequestFailed,
3387            true,
3388            0,
3389        )
3390    }
3391
3392    fn request_failed_audit(
3393        rest: usize,
3394        themes: usize,
3395        plugins: usize,
3396    ) -> WebAssessmentWordPressDiscoveryAudit {
3397        let sources = (0..rest)
3398            .map(|ordinal| request_failed_source(WordPressDiscoverySourceKind::RestIndex, ordinal))
3399            .chain((0..themes).map(|ordinal| {
3400                request_failed_source(WordPressDiscoverySourceKind::ThemeStylesheet, ordinal)
3401            }))
3402            .chain((0..plugins).map(|ordinal| {
3403                request_failed_source(WordPressDiscoverySourceKind::PluginReadme, ordinal)
3404            }))
3405            .collect::<Vec<_>>();
3406        let attempted_request_count = u8::try_from(sources.len()).unwrap();
3407        let application = Url::parse("https://example.test/").unwrap();
3408        let mut layout = WordPressDiscoveryLayoutAudit::unresolved(&application, None);
3409        for (role, count, base) in [
3410            (WordPressDiscoveryLayoutRole::RestIndex, rest, application),
3411            (
3412                WordPressDiscoveryLayoutRole::Themes,
3413                themes,
3414                Url::parse("https://example.test/wp-content/themes/").unwrap(),
3415            ),
3416            (
3417                WordPressDiscoveryLayoutRole::Plugins,
3418                plugins,
3419                Url::parse("https://example.test/wp-content/plugins/").unwrap(),
3420            ),
3421        ] {
3422            if count > 0 {
3423                let entry = layout.role_mut(role);
3424                entry.status = WordPressDiscoveryLayoutStatus::Exact;
3425                entry.basis = if role == WordPressDiscoveryLayoutRole::RestIndex {
3426                    WordPressDiscoveryLayoutBasis::StructuredAdvertisement
3427                } else {
3428                    WordPressDiscoveryLayoutBasis::ConventionalAsset
3429                };
3430                entry.reference = Some(opaque_url_reference("wordpress-discovery-role", &base));
3431                entry.candidate_count = 1;
3432            }
3433        }
3434        WebAssessmentWordPressDiscoveryAudit {
3435            seed_count: attempted_request_count,
3436            candidate_count: attempted_request_count,
3437            candidate_limit_reached: false,
3438            omitted_candidate_count: 0,
3439            attempted_request_count,
3440            completed_response_count: 0,
3441            committed_response_count: 0,
3442            response_bytes: 0,
3443            sources,
3444            layout,
3445            page_scope: None,
3446            supplied_session_pages: None,
3447        }
3448    }
3449
3450    #[test]
3451    fn discovery_audit_enforces_each_request_class_ceiling() {
3452        assert!(request_failed_audit(1, 3, 8).is_internally_consistent());
3453        assert!(!attempted_request_classes_within_limits(
3454            &request_failed_audit(2, 2, 8).sources
3455        ));
3456        assert!(!request_failed_audit(0, 4, 8).is_internally_consistent());
3457        assert!(!request_failed_audit(0, 3, 9).is_internally_consistent());
3458    }
3459
3460    #[test]
3461    fn theme_header_requires_theme_name_and_retains_bounded_fields() {
3462        assert!(parse_theme_metadata(b"/* Version: 9.9.9 */")
3463            .unwrap()
3464            .is_none());
3465
3466        let metadata = parse_theme_metadata(
3467            b"/*\nTheme Name: Child Theme\nVersion: 1.2.3\nTemplate: parent-theme\nRequires at least: 6.5\nRequires PHP: 8.1\nTested up to: 6.9\n*/",
3468        )
3469        .unwrap()
3470        .unwrap();
3471        assert_eq!(metadata.name(), Some("Child Theme"));
3472        assert_eq!(metadata.version(), Some("1.2.3"));
3473        assert_eq!(metadata.template(), Some("parent-theme"));
3474        assert_eq!(metadata.requires_wordpress(), Some("6.5"));
3475        assert_eq!(metadata.requires_php(), Some("8.1"));
3476        assert_eq!(metadata.tested_up_to(), Some("6.9"));
3477    }
3478
3479    #[test]
3480    fn plugin_stable_tag_is_metadata_not_a_component_version_signal() {
3481        let metadata = parse_plugin_metadata(
3482            b"=== Cache Tool ===\nStable tag: 4.5.6\nRequires at least: 6.4\nRequires PHP: 8.0\nTested up to: 6.9\n\nDescription",
3483        )
3484        .unwrap()
3485        .unwrap();
3486        assert_eq!(metadata.name(), Some("Cache Tool"));
3487        assert_eq!(metadata.stable_tag(), Some("4.5.6"));
3488        assert_eq!(metadata.requires_wordpress(), Some("6.4"));
3489        assert_eq!(metadata.requires_php(), Some("8.0"));
3490        assert_eq!(metadata.tested_up_to(), Some("6.9"));
3491    }
3492
3493    #[test]
3494    fn duplicate_or_oversized_metadata_fields_fail_closed() {
3495        assert!(parse_theme_metadata(b"/* Theme Name: A\nTheme Name: B */").is_err());
3496        let oversized = format!("=== Plugin ===\nStable tag: {}", "a".repeat(257));
3497        assert!(parse_plugin_metadata(oversized.as_bytes()).is_err());
3498        let unsupported_version = format!(
3499            "/* Theme Name: Bounded\nVersion: {} */",
3500            "7".repeat(MAX_DISCOVERED_VERSION_BYTES + 1)
3501        );
3502        assert_eq!(
3503            parse_theme_metadata(unsupported_version.as_bytes()),
3504            Err(ParseMetadataError::Unsupported)
3505        );
3506    }
3507
3508    #[test]
3509    fn clipped_metadata_requires_a_complete_header_boundary() {
3510        let mut theme = b"/*\nTheme Name: Bounded\nVersion: 1.2.3\n".to_vec();
3511        theme.resize(MAX_METADATA_HEADER_BYTES + 1, b'x');
3512        assert_eq!(
3513            parse_theme_metadata(&theme),
3514            Err(ParseMetadataError::Truncated)
3515        );
3516
3517        let mut plugin = b"=== Bounded ===\nStable tag: ".to_vec();
3518        plugin.resize(MAX_METADATA_HEADER_BYTES + 1, b'7');
3519        assert_eq!(
3520            parse_plugin_metadata(&plugin),
3521            Err(ParseMetadataError::Truncated)
3522        );
3523    }
3524
3525    #[test]
3526    fn clipped_utf8_after_a_complete_plugin_header_does_not_change_metadata() {
3527        let mut plugin = b"=== Bounded ===\nStable tag: 1.2.3\n\n".to_vec();
3528        plugin.resize(MAX_METADATA_HEADER_BYTES - 1, b'x');
3529        plugin.extend_from_slice("é".as_bytes());
3530        let metadata = parse_plugin_metadata(&plugin).unwrap().unwrap();
3531        assert_eq!(metadata.stable_tag(), Some("1.2.3"));
3532    }
3533
3534    #[test]
3535    fn rest_json_rejects_duplicate_unknown_keys_at_every_object_depth() {
3536        assert_eq!(
3537            validate_rest_json_shape(br#"{"unknown":{"ordinary":true},"namespaces":[]}"#),
3538            Ok(())
3539        );
3540        for input in [
3541            br#"{"x":1,"x":2,"namespaces":[]}"#.as_slice(),
3542            br#"{"unknown":{"x":1,"x":2},"namespaces":[]}"#.as_slice(),
3543            br#"{"x":1,"\u0078":2,"namespaces":[]}"#.as_slice(),
3544        ] {
3545            assert_eq!(
3546                validate_rest_json_shape(input),
3547                Err(ParseMetadataError::Malformed)
3548            );
3549        }
3550    }
3551
3552    #[test]
3553    fn rest_json_bounds_every_decoded_key_and_string() {
3554        let boundary_value = "v".repeat(MAX_REST_JSON_STRING_BYTES);
3555        let boundary_key = "k".repeat(MAX_REST_JSON_KEY_BYTES);
3556        assert_eq!(
3557            validate_rest_json_shape(
3558                format!(r#"{{"{boundary_key}":"{boundary_value}"}}"#).as_bytes()
3559            ),
3560            Ok(())
3561        );
3562
3563        let oversized_value = "v".repeat(MAX_REST_JSON_STRING_BYTES + 1);
3564        let oversized_key = "k".repeat(MAX_REST_JSON_KEY_BYTES + 1);
3565        for input in [
3566            format!(r#"{{"value":"{oversized_value}"}}"#),
3567            format!(r#"{{"{oversized_key}":null}}"#),
3568            format!(
3569                r#"{{"{}":null}}"#,
3570                "\\u006b".repeat(MAX_REST_JSON_KEY_BYTES + 1)
3571            ),
3572            format!(
3573                r#"{{"value":"{}"}}"#,
3574                "\\u0076".repeat(MAX_REST_JSON_STRING_BYTES + 1)
3575            ),
3576        ] {
3577            assert_eq!(
3578                validate_rest_json_shape(input.as_bytes()),
3579                Err(ParseMetadataError::Unsupported)
3580            );
3581        }
3582    }
3583
3584    #[test]
3585    fn rest_json_collection_limit_probes_remain_strictly_decoded() {
3586        let array_boundary = std::iter::repeat_n("null", MAX_REST_JSON_COLLECTION_LENGTH)
3587            .collect::<Vec<_>>()
3588            .join(",");
3589        assert_eq!(
3590            validate_rest_json_shape(format!("[{array_boundary}]").as_bytes()),
3591            Ok(())
3592        );
3593        assert_eq!(
3594            validate_rest_json_shape(format!("[{array_boundary},null]").as_bytes()),
3595            Err(ParseMetadataError::Unsupported)
3596        );
3597        assert_eq!(
3598            validate_rest_json_shape(
3599                format!(r#"[{array_boundary},{{"duplicate":1,"duplicate":2}}]"#).as_bytes()
3600            ),
3601            Err(ParseMetadataError::Malformed),
3602            "the first over-limit element must still pass through the duplicate-key validator"
3603        );
3604
3605        let map_boundary = (0..MAX_REST_JSON_COLLECTION_LENGTH)
3606            .map(|index| format!(r#""key-{index}":null"#))
3607            .collect::<Vec<_>>()
3608            .join(",");
3609        assert_eq!(
3610            validate_rest_json_shape(format!("{{{map_boundary}}}").as_bytes()),
3611            Ok(())
3612        );
3613        assert_eq!(
3614            validate_rest_json_shape(format!(r#"{{{map_boundary},"extra":null}}"#).as_bytes()),
3615            Err(ParseMetadataError::Unsupported)
3616        );
3617        assert_eq!(
3618            validate_rest_json_shape(format!(r#"{{{map_boundary},"\u006bey-0":null}}"#).as_bytes()),
3619            Err(ParseMetadataError::Malformed),
3620            "the first over-limit key must still pass through bounded duplicate detection"
3621        );
3622    }
3623
3624    #[test]
3625    fn rest_json_depth_boundary_is_preserved() {
3626        let nested = |depth: usize| {
3627            let mut value = "null".to_owned();
3628            for _ in 0..depth {
3629                value = format!("[{value}]");
3630            }
3631            value
3632        };
3633        assert_eq!(
3634            validate_rest_json_shape(nested(MAX_REST_JSON_DEPTH).as_bytes()),
3635            Ok(())
3636        );
3637        assert_eq!(
3638            validate_rest_json_shape(nested(MAX_REST_JSON_DEPTH + 1).as_bytes()),
3639            Err(ParseMetadataError::Unsupported)
3640        );
3641    }
3642}