Skip to main content

termivar_scanner/web_runtime/
wordpress_runtime.rs

1//! WordPress interpretation bound to the existing root-response observation.
2//!
3//! This module owns no transport. It retains only bounded typed signals and
4//! existing evidence references, then evaluates them with already-validated
5//! local inputs at the assessment's normal projection boundary.
6
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    sync::{Arc, Mutex},
10};
11
12use html5ever::{ns, parse_document, tendril::TendrilSink, ParseOpts};
13use markup5ever_rcdom::{NodeData, RcDom};
14use sha2::Digest;
15use termivar_core::{
16    ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
17    HttpEvidencePredicate, KnowledgePredicate,
18};
19use url::Url;
20
21#[cfg(feature = "supplied-session-review")]
22use crate::http_evidence::CollectedHttpResponse;
23#[cfg(feature = "supplied-session-review")]
24use crate::supplied_session_review::{
25    SuppliedSessionCredentialMechanism, SuppliedSessionPolicy, SuppliedSessionPolicyVersion,
26    SuppliedSessionRequestDescriptor,
27};
28use crate::{
29    http_evidence::WordPressRestIndexAdvertisement,
30    wordpress_review::{
31        evaluate_wordpress_review, WordPressComponentKind, WordPressComponentSignal,
32        WordPressDiscoveryLayout, WordPressReviewError, WordPressReviewInputs,
33        WordPressReviewResult, MAX_WORDPRESS_SIGNALS,
34    },
35    KnowledgeBase,
36};
37
38use super::assessment_item::{
39    AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
40    AssessmentProjectionContext,
41};
42#[cfg(feature = "supplied-session-review")]
43use super::supplied_session_runtime::{
44    CommittedSuppliedSessionResourceReceipt, SuppliedSessionCommittedResourceObserver,
45    WebAssessmentSuppliedSessionAudit,
46};
47use super::wordpress_fingerprint_runtime::{
48    execute_wordpress_asset_fingerprints, WordPressAssetFingerprintAllowance,
49    WordPressAssetFingerprintCollector, WordPressAssetFingerprintRuntimeError,
50    WordPressAssetFingerprintStop, WordPressObservedAssetCandidate,
51    MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES,
52};
53pub use super::wordpress_fingerprint_runtime::{
54    WordPressAssetFingerprintAcquisition, WordPressAssetFingerprintExecution,
55    WordPressAssetFingerprintResourceOutcome, WordPressAssetFingerprintResourceReceipt,
56};
57
58pub(super) use super::wordpress_discovery::WordPressDiscoveryStop;
59use super::wordpress_discovery::{
60    discovery_seed_fingerprint, execute_wordpress_discovery, WordPressDiscoveryAssociation,
61    WordPressDiscoveryExecutionError, WordPressDiscoveryExecutionInput,
62    WordPressDiscoveryLayoutAudit, WordPressDiscoveryLayoutBasis, WordPressDiscoveryLayoutRole,
63    WordPressDiscoveryLayoutStatus, WordPressDiscoverySeed,
64    WordPressSuppliedSessionPageAcquisition, MAX_WORDPRESS_DISCOVERY_CANDIDATES,
65    MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES, MAX_WORDPRESS_DISCOVERY_PLUGIN_REQUESTS,
66    MAX_WORDPRESS_DISCOVERY_REST_REQUESTS, MAX_WORDPRESS_DISCOVERY_THEME_REQUESTS,
67    MAX_WORDPRESS_PAGE_CANDIDATES, MAX_WORDPRESS_PAGE_INTERPRETED_BYTES,
68    MAX_WORDPRESS_PAGE_RESPONSE_BYTES, MAX_WORDPRESS_SELECTED_PAGES,
69};
70pub use super::wordpress_discovery::{
71    WebAssessmentWordPressDiscoveryAudit, WordPressDiscoverySourceAudit,
72    WordPressDiscoverySourceKind, WordPressDiscoverySourceOutcome, WordPressPageAcquisition,
73    WordPressPageAssociation, WordPressPageAudit, WordPressPageOutcome, WordPressPageScope,
74    WordPressPageScopeAudit, WordPressPluginDiscoveryMetadata,
75    WordPressSuppliedSessionCredentialMechanism, WordPressSuppliedSessionPageAudit,
76    WordPressSuppliedSessionPageOutcome, WordPressSuppliedSessionPageScopeAudit,
77    WordPressThemeDiscoveryMetadata,
78};
79
80/// Observation-only product item emitted when the root response carried at
81/// least one supported structured WordPress hint.
82pub const WORDPRESS_REVIEW_CAPABILITY_ID: &str = "technology.wordpress-surface-observed@1";
83/// Observation-only item that retains document-local references for fetched
84/// metadata without changing the root-surface item's meaning.
85pub const WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID: &str =
86    "technology.wordpress-metadata-source-response-observed@1";
87
88static WORDPRESS_REVIEW_CAPABILITY: AssessmentCapabilityDescriptor =
89    AssessmentCapabilityDescriptor::informational(
90        WORDPRESS_REVIEW_CAPABILITY_ID,
91        "WordPress surface hints observed",
92        "wordpress-surface",
93        "The root response contained bounded structured WordPress hints; installation authenticity and advisory impact were not established.",
94        600_000,
95        "wordpress-inventory-review",
96        "Confirm the component inventory and consult an appropriate authoritative advisory source before making a security decision.",
97    );
98
99static WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY: AssessmentCapabilityDescriptor =
100    AssessmentCapabilityDescriptor::informational(
101        WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY_ID,
102        "WordPress metadata-source response outcome observed",
103        "wordpress-metadata-source-response",
104        "Bounded response evidence from selected public WordPress metadata sources was collected; usable metadata, installation authenticity, vulnerable-code reachability, and advisory impact were not established.",
105        550_000,
106        "wordpress-metadata-review",
107        "Confirm the installation inventory and source-qualified metadata before making a security or remediation decision.",
108    );
109
110#[derive(Default)]
111struct CollectedSignals {
112    signals: Vec<WordPressComponentSignal>,
113    discovery_seeds: Vec<WordPressDiscoverySeed>,
114    discovery_candidate_fingerprints: BTreeSet<[u8; 32]>,
115    evidence_ids: BTreeSet<EvidenceId>,
116    discovery_seed_evidence_ids: BTreeSet<EvidenceId>,
117    signal_limit_exceeded: bool,
118    discovery_candidate_identity_limit_exceeded: bool,
119    discovery_layout: Option<WordPressDiscoveryLayoutAudit>,
120    page_scope: Option<CollectedPageScope>,
121    supplied_session: Option<CollectedSuppliedSessionPages>,
122}
123
124struct CollectedSuppliedSessionPages {
125    acquisition: Option<WordPressSuppliedSessionPageAcquisition>,
126    epoch: u8,
127    pending_entry_layout: Option<(FrozenWordPressPageLayout, Vec<EvidenceId>)>,
128    frozen_layout: Option<FrozenWordPressPageLayout>,
129    policy_reference: Option<String>,
130    application_reference: Option<String>,
131    principal_reference: Option<String>,
132    credential_mechanism: Option<WordPressSuppliedSessionCredentialMechanism>,
133    pages: BTreeMap<u8, WordPressSuppliedSessionPageAudit>,
134    pending: BTreeMap<u8, PendingSuppliedSessionInterpretation>,
135    failed: bool,
136}
137
138enum PendingSuppliedSessionInterpretation {
139    #[cfg(feature = "supplied-session-review")]
140    Parsed(Box<PendingWordPressPageObservation>),
141    #[cfg(feature = "supplied-session-review")]
142    UnsupportedContent,
143    #[cfg(feature = "supplied-session-review")]
144    InvalidUtf8,
145}
146
147impl CollectedSuppliedSessionPages {
148    fn new() -> Self {
149        Self {
150            acquisition: None,
151            epoch: 1,
152            pending_entry_layout: None,
153            frozen_layout: None,
154            policy_reference: None,
155            application_reference: None,
156            principal_reference: None,
157            credential_mechanism: None,
158            pages: BTreeMap::new(),
159            pending: BTreeMap::new(),
160            failed: false,
161        }
162    }
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub(super) struct WordPressPageCandidate {
167    url: Url,
168    page_reference: String,
169}
170
171#[derive(Clone, Default)]
172pub(super) struct WordPressPageCandidateSelection {
173    candidates: Vec<WordPressPageCandidate>,
174    candidate_count: u16,
175    omitted_candidate_count: u16,
176    candidate_identity_limit_exceeded: bool,
177}
178
179#[derive(Clone, Default)]
180pub(super) struct FrozenWordPressPageLayout {
181    application_url: Option<Url>,
182    themes: Option<FrozenWordPressRoleBinding>,
183    plugins: Option<FrozenWordPressRoleBinding>,
184}
185
186#[derive(Clone)]
187struct FrozenWordPressRoleBinding {
188    base_url: Url,
189    association: WordPressDiscoveryAssociation,
190}
191
192pub(super) struct PendingWordPressPageObservation {
193    page: WordPressPageCandidate,
194    signals: Vec<WordPressComponentSignal>,
195    discovery: WordPressDiscoverySeedSelection,
196    association: WordPressPageAssociation,
197    outcome: WordPressPageOutcome,
198    interpreted_response_bytes: u64,
199    parent_evidence_ids: Vec<EvidenceId>,
200    pub(super) asset_fingerprint_candidates: Vec<WordPressObservedAssetCandidate>,
201    pub(super) asset_fingerprint_candidate_limit_exceeded: bool,
202}
203
204struct CollectedPageScope {
205    mode: WordPressPageScope,
206    entry_page_reference: String,
207    selection: WordPressPageCandidateSelection,
208    frozen_layout: FrozenWordPressPageLayout,
209    pending_entry: Option<PendingWordPressEntryObservation>,
210    pending: BTreeMap<String, PendingWordPressPageObservation>,
211    pages: Vec<WordPressPageAudit>,
212}
213
214impl CollectedPageScope {
215    fn new(mode: WordPressPageScope, application_url: &Url) -> Self {
216        Self {
217            mode,
218            entry_page_reference: super::wordpress_discovery::opaque_url_reference(
219                "wordpress-discovery-page",
220                application_url,
221            ),
222            selection: WordPressPageCandidateSelection::default(),
223            frozen_layout: FrozenWordPressPageLayout::default(),
224            pending_entry: None,
225            pending: BTreeMap::new(),
226            pages: Vec::new(),
227        }
228    }
229}
230
231struct PendingWordPressEntryObservation {
232    signals: Vec<WordPressComponentSignal>,
233    discovery: WordPressDiscoverySeedSelection,
234    page_candidates: WordPressPageCandidateSelection,
235    frozen_page_layout: FrozenWordPressPageLayout,
236    parent_evidence_ids: Vec<EvidenceId>,
237}
238
239type WordPressDiscoverySnapshot = (
240    Vec<WordPressDiscoverySeed>,
241    Vec<EvidenceId>,
242    u64,
243    BTreeSet<[u8; 32]>,
244    Option<WordPressDiscoveryLayoutAudit>,
245);
246
247#[derive(Default)]
248pub(super) struct WordPressDiscoverySeedSelection {
249    seeds: Vec<WordPressDiscoverySeed>,
250    seen_candidate_fingerprints: BTreeSet<[u8; 32]>,
251    candidate_identity_limit_exceeded: bool,
252    omitted_candidate_count: u64,
253    layout: Option<WordPressDiscoveryLayoutAudit>,
254}
255
256#[derive(Clone)]
257pub(super) struct WordPressDiscoveryContext {
258    application_url: Url,
259    declared_layout: Option<WordPressDiscoveryLayout>,
260}
261
262impl WordPressDiscoveryContext {
263    fn new(application_url: Url, declared_layout: Option<WordPressDiscoveryLayout>) -> Self {
264        Self {
265            application_url,
266            declared_layout,
267        }
268    }
269
270    pub(super) fn application_url(&self) -> &Url {
271        &self.application_url
272    }
273
274    fn declared_layout(&self) -> Option<&WordPressDiscoveryLayout> {
275        self.declared_layout.as_ref()
276    }
277
278    fn unresolved_audit(&self) -> WordPressDiscoveryLayoutAudit {
279        WordPressDiscoveryLayoutAudit::unresolved(
280            &self.application_url,
281            self.declared_layout.as_ref(),
282        )
283    }
284}
285
286pub(super) struct WordPressEntryObservation {
287    pub(super) signals: Vec<WordPressComponentSignal>,
288    pub(super) discovery: WordPressDiscoverySeedSelection,
289    pub(super) page_candidates: WordPressPageCandidateSelection,
290    pub(super) frozen_page_layout: FrozenWordPressPageLayout,
291    pub(super) asset_fingerprint_candidates: Vec<WordPressObservedAssetCandidate>,
292    pub(super) asset_fingerprint_candidate_limit_exceeded: bool,
293}
294
295#[derive(Clone)]
296struct ObservedAssetReference {
297    raw_reference: String,
298    resolution_base: Url,
299    resolved_url: Url,
300}
301
302impl WordPressDiscoverySeedSelection {
303    fn insert(&mut self, seed: WordPressDiscoverySeed) {
304        let fingerprint = discovery_seed_fingerprint(&seed);
305        if self.seen_candidate_fingerprints.contains(&fingerprint) {
306            if let Some(existing) = self
307                .seeds
308                .iter_mut()
309                .find(|existing| discovery_seed_fingerprint(existing) == fingerprint)
310            {
311                existing.merge_source_provenance(&seed);
312            }
313            return;
314        }
315        if self.seen_candidate_fingerprints.len() == MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES {
316            self.candidate_identity_limit_exceeded = true;
317            return;
318        }
319        self.seen_candidate_fingerprints.insert(fingerprint);
320        if insert_bounded_discovery_seed(&mut self.seeds, seed, &mut self.omitted_candidate_count)
321            .is_err()
322        {
323            self.candidate_identity_limit_exceeded = true;
324        }
325    }
326
327    #[cfg(test)]
328    fn seeds(&self) -> &[WordPressDiscoverySeed] {
329        &self.seeds
330    }
331}
332
333/// One assessment-owned sink shared only with its root discovery observer.
334#[derive(Clone, Default)]
335pub(super) struct WordPressSignalCollector(Arc<Mutex<CollectedSignals>>);
336
337impl WordPressSignalCollector {
338    fn new(
339        page_scope: Option<WordPressPageScope>,
340        application_url: &Url,
341        supplied_session_selected: bool,
342    ) -> Self {
343        let state = CollectedSignals {
344            page_scope: page_scope.map(|mode| CollectedPageScope::new(mode, application_url)),
345            supplied_session: supplied_session_selected.then(CollectedSuppliedSessionPages::new),
346            ..CollectedSignals::default()
347        };
348        Self(Arc::new(Mutex::new(state)))
349    }
350
351    pub(super) fn stage_entry(
352        &self,
353        observation: WordPressEntryObservation,
354        parent_evidence_ids: Vec<EvidenceId>,
355    ) {
356        let mut state = self
357            .0
358            .lock()
359            .unwrap_or_else(|poisoned| poisoned.into_inner());
360        if let Some(session) = state.supplied_session.as_mut() {
361            if session.pending_entry_layout.is_some() || session.frozen_layout.is_some() {
362                session.failed = true;
363            } else {
364                session.pending_entry_layout = Some((
365                    observation.frozen_page_layout.clone(),
366                    parent_evidence_ids.clone(),
367                ));
368            }
369        }
370        let Some(page_scope) = state.page_scope.as_mut() else {
371            drop(state);
372            self.record(
373                observation.signals,
374                observation.discovery,
375                parent_evidence_ids,
376            );
377            return;
378        };
379        if page_scope.pending_entry.is_some() {
380            state.discovery_candidate_identity_limit_exceeded = true;
381            return;
382        }
383        page_scope.pending_entry = Some(PendingWordPressEntryObservation {
384            signals: observation.signals,
385            discovery: observation.discovery,
386            page_candidates: observation.page_candidates,
387            frozen_page_layout: observation.frozen_page_layout,
388            parent_evidence_ids,
389        });
390    }
391
392    pub(super) fn commit_staged_entry(
393        &self,
394        knowledge: &KnowledgeBase,
395        expected_subject: &EntityId,
396        expected_url: &Url,
397    ) -> Result<(), WordPressReviewError> {
398        let session_pending = {
399            let mut state = self
400                .0
401                .lock()
402                .unwrap_or_else(|poisoned| poisoned.into_inner());
403            state
404                .supplied_session
405                .as_mut()
406                .and_then(|session| session.pending_entry_layout.take())
407        };
408        if let Some((frozen_layout, parent_evidence_ids)) = session_pending {
409            if !committed_parent_evidence_is_valid(
410                knowledge,
411                &parent_evidence_ids,
412                expected_subject,
413                expected_url,
414            ) {
415                self.fail_supplied_session_observation();
416                return Err(WordPressReviewError::SignalLimitExceeded);
417            }
418            let mut state = self
419                .0
420                .lock()
421                .unwrap_or_else(|poisoned| poisoned.into_inner());
422            let session = state
423                .supplied_session
424                .as_mut()
425                .ok_or(WordPressReviewError::SignalLimitExceeded)?;
426            if session.frozen_layout.replace(frozen_layout).is_some() {
427                session.failed = true;
428                return Err(WordPressReviewError::SignalLimitExceeded);
429            }
430        }
431        let (signals, mut discovery, evidence_ids, entry_reference) = {
432            let mut state = self
433                .0
434                .lock()
435                .unwrap_or_else(|poisoned| poisoned.into_inner());
436            let Some(page_scope) = state.page_scope.as_mut() else {
437                return Ok(());
438            };
439            let Some(pending) = page_scope.pending_entry.take() else {
440                return Ok(());
441            };
442            if !committed_parent_evidence_is_valid(
443                knowledge,
444                &pending.parent_evidence_ids,
445                expected_subject,
446                expected_url,
447            ) {
448                return Err(WordPressReviewError::SignalLimitExceeded);
449            }
450            page_scope.selection = pending.page_candidates;
451            page_scope.frozen_layout = pending.frozen_page_layout;
452            (
453                pending.signals,
454                pending.discovery,
455                pending.parent_evidence_ids,
456                page_scope.entry_page_reference.clone(),
457            )
458        };
459        for seed in &mut discovery.seeds {
460            *seed = seed
461                .clone()
462                .with_source_page_reference(entry_reference.clone());
463        }
464        self.record(signals, discovery, evidence_ids);
465        Ok(())
466    }
467
468    pub(super) fn selected_page(
469        &self,
470        url: &Url,
471    ) -> Option<(WordPressPageCandidate, FrozenWordPressPageLayout)> {
472        let state = self
473            .0
474            .lock()
475            .unwrap_or_else(|poisoned| poisoned.into_inner());
476        let page_scope = state.page_scope.as_ref()?;
477        let candidate = page_scope
478            .selection
479            .candidates
480            .iter()
481            .find(|candidate| &candidate.url == url)?;
482        Some((candidate.clone(), page_scope.frozen_layout.clone()))
483    }
484
485    pub(super) fn stage_page(&self, observation: PendingWordPressPageObservation) {
486        let mut state = self
487            .0
488            .lock()
489            .unwrap_or_else(|poisoned| poisoned.into_inner());
490        let Some(page_scope) = state.page_scope.as_mut() else {
491            return;
492        };
493        if page_scope
494            .selection
495            .candidates
496            .iter()
497            .any(|candidate| candidate == &observation.page)
498        {
499            page_scope
500                .pending
501                .entry(observation.page.url.as_str().to_owned())
502                .or_insert(observation);
503        }
504    }
505
506    pub(super) fn commit_staged_page(
507        &self,
508        knowledge: &KnowledgeBase,
509        root_subject: &EntityId,
510        page_subject: &EntityId,
511        page_url: &Url,
512    ) -> Result<(), WordPressReviewError> {
513        let pending = {
514            let mut state = self
515                .0
516                .lock()
517                .unwrap_or_else(|poisoned| poisoned.into_inner());
518            let Some(page_scope) = state.page_scope.as_mut() else {
519                return Ok(());
520            };
521            page_scope.pending.remove(page_url.as_str())
522        };
523        let Some(mut pending) = pending else {
524            return Ok(());
525        };
526        if !committed_parent_evidence_is_valid(
527            knowledge,
528            &pending.parent_evidence_ids,
529            page_subject,
530            page_url,
531        ) {
532            return Err(WordPressReviewError::SignalLimitExceeded);
533        }
534        let already_interpreted = {
535            let state = self
536                .0
537                .lock()
538                .unwrap_or_else(|poisoned| poisoned.into_inner());
539            state
540                .page_scope
541                .as_ref()
542                .and_then(|page_scope| {
543                    page_scope.pages.iter().try_fold(0_u64, |total, page| {
544                        total.checked_add(page.interpreted_response_bytes)
545                    })
546                })
547                .ok_or(WordPressReviewError::SignalLimitExceeded)?
548        };
549        if already_interpreted
550            .checked_add(pending.interpreted_response_bytes)
551            .is_none_or(|total| total > MAX_WORDPRESS_PAGE_INTERPRETED_BYTES)
552        {
553            pending.signals.clear();
554            pending.discovery = WordPressDiscoverySeedSelection::default();
555            pending.association = WordPressPageAssociation::Rejected;
556            pending.outcome = WordPressPageOutcome::Truncated;
557            pending.interpreted_response_bytes = 0;
558        }
559        let evidence = root_page_observation_evidence(
560            root_subject,
561            &pending.parent_evidence_ids,
562            pending.page.page_reference.as_str(),
563        )?;
564        let evidence_id = evidence.id().clone();
565        knowledge
566            .insert_evidence_batch(vec![evidence])
567            .map_err(|_| WordPressReviewError::SignalLimitExceeded)?;
568        if pending.association == WordPressPageAssociation::Accepted {
569            for seed in &mut pending.discovery.seeds {
570                *seed = seed
571                    .clone()
572                    .with_source_evidence(evidence_id.clone())
573                    .with_source_page_reference(pending.page.page_reference.clone());
574            }
575            self.record(pending.signals, pending.discovery, [evidence_id.clone()]);
576        }
577        let mut state = self
578            .0
579            .lock()
580            .unwrap_or_else(|poisoned| poisoned.into_inner());
581        let page_scope = state
582            .page_scope
583            .as_mut()
584            .ok_or(WordPressReviewError::SignalLimitExceeded)?;
585        if page_scope
586            .pages
587            .iter()
588            .any(|page| page.page_reference == pending.page.page_reference)
589        {
590            return Err(WordPressReviewError::SignalLimitExceeded);
591        }
592        page_scope.pages.push(WordPressPageAudit {
593            page_reference: pending.page.page_reference,
594            acquisition: WordPressPageAcquisition::Reused,
595            association: pending.association,
596            outcome: pending.outcome,
597            request_attempted: false,
598            interpreted_response_bytes: pending.interpreted_response_bytes,
599            response_bytes: 0,
600            evidence_ids: vec![evidence_id],
601        });
602        Ok(())
603    }
604
605    pub(super) fn record(
606        &self,
607        signals: impl IntoIterator<Item = WordPressComponentSignal>,
608        mut discovery: WordPressDiscoverySeedSelection,
609        evidence_ids: impl IntoIterator<Item = EvidenceId>,
610    ) {
611        let evidence_ids = evidence_ids.into_iter().collect::<Vec<_>>();
612        let mut state = self
613            .0
614            .lock()
615            .unwrap_or_else(|poisoned| poisoned.into_inner());
616        state.discovery_candidate_identity_limit_exceeded |=
617            discovery.candidate_identity_limit_exceeded;
618        if let Some(layout) = discovery.layout.take() {
619            if state
620                .discovery_layout
621                .as_ref()
622                .is_some_and(|existing| existing != &layout)
623            {
624                state.discovery_candidate_identity_limit_exceeded = true;
625            } else {
626                state.discovery_layout = Some(layout);
627            }
628        }
629        let mut retained_signal = false;
630        for signal in signals {
631            if state.signals.contains(&signal) {
632                retained_signal = true;
633                continue;
634            }
635            if state.signals.len() == MAX_WORDPRESS_SIGNALS {
636                state.signal_limit_exceeded = true;
637                break;
638            }
639            state.signals.push(signal);
640            retained_signal = true;
641        }
642        if let (Some(existing), Some(incoming)) = (
643            state
644                .discovery_seeds
645                .iter()
646                .find(|seed| seed.kind() == WordPressDiscoverySourceKind::RestIndex)
647                .cloned(),
648            discovery
649                .seeds
650                .iter()
651                .find(|seed| seed.kind() == WordPressDiscoverySourceKind::RestIndex)
652                .cloned(),
653        ) {
654            let conflicts = existing.preflight_outcome()
655                == Some(WordPressDiscoverySourceOutcome::InvalidAdvertisement)
656                || incoming.preflight_outcome()
657                    == Some(WordPressDiscoverySourceOutcome::InvalidAdvertisement)
658                || !same_discovery_seed(&existing, &incoming);
659            if conflicts {
660                state
661                    .discovery_candidate_fingerprints
662                    .remove(&discovery_seed_fingerprint(&existing));
663                discovery
664                    .seen_candidate_fingerprints
665                    .remove(&discovery_seed_fingerprint(&incoming));
666                state
667                    .discovery_seeds
668                    .retain(|seed| seed.kind() != WordPressDiscoverySourceKind::RestIndex);
669                discovery
670                    .seeds
671                    .retain(|seed| seed.kind() != WordPressDiscoverySourceKind::RestIndex);
672                let invalid = WordPressDiscoverySeed::invalid_rest_index(existing.url().clone());
673                let invalid_fingerprint = discovery_seed_fingerprint(&invalid);
674                if state.discovery_candidate_fingerprints.len()
675                    == MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
676                    && !state
677                        .discovery_candidate_fingerprints
678                        .contains(&invalid_fingerprint)
679                {
680                    state.discovery_candidate_identity_limit_exceeded = true;
681                } else {
682                    state
683                        .discovery_candidate_fingerprints
684                        .insert(invalid_fingerprint);
685                    state.discovery_seeds.push(invalid);
686                }
687            }
688        }
689        for fingerprint in discovery.seen_candidate_fingerprints {
690            if state
691                .discovery_candidate_fingerprints
692                .contains(&fingerprint)
693            {
694                continue;
695            }
696            if state.discovery_candidate_fingerprints.len()
697                == MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
698            {
699                state.discovery_candidate_identity_limit_exceeded = true;
700                break;
701            }
702            state.discovery_candidate_fingerprints.insert(fingerprint);
703        }
704        let mut retained_discovery_seed = false;
705        for seed in discovery.seeds {
706            let discovery_seeds = &mut state.discovery_seeds;
707            let fingerprint = discovery_seed_fingerprint(&seed);
708            if let Some(existing) = discovery_seeds
709                .iter_mut()
710                .find(|existing| discovery_seed_fingerprint(existing) == fingerprint)
711            {
712                existing.merge_source_provenance(&seed);
713                retained_discovery_seed = true;
714                continue;
715            }
716            let mut ignored_local_omissions = 0;
717            match insert_bounded_discovery_seed(discovery_seeds, seed, &mut ignored_local_omissions)
718            {
719                Ok(true) => retained_discovery_seed = true,
720                Ok(false) => {},
721                Err(()) => {
722                    state.signal_limit_exceeded = true;
723                    break;
724                },
725            }
726        }
727        if !state.discovery_seeds.is_empty() {
728            retained_discovery_seed = true;
729        }
730        if retained_signal {
731            state.evidence_ids.extend(evidence_ids.iter().cloned());
732        }
733        if retained_discovery_seed {
734            state.discovery_seed_evidence_ids.extend(evidence_ids);
735        }
736    }
737
738    fn snapshot(
739        &self,
740    ) -> Result<(Vec<WordPressComponentSignal>, Vec<EvidenceId>), WordPressReviewError> {
741        let state = self
742            .0
743            .lock()
744            .unwrap_or_else(|poisoned| poisoned.into_inner());
745        if state.signal_limit_exceeded {
746            return Err(WordPressReviewError::SignalLimitExceeded);
747        }
748        let mut signals = state.signals.clone();
749        signals.sort_by(|left, right| {
750            left.identity()
751                .cmp(right.identity())
752                .then_with(|| left.source().cmp(&right.source()))
753                .then_with(|| left.version().cmp(&right.version()))
754        });
755        Ok((signals, state.evidence_ids.iter().cloned().collect()))
756    }
757
758    fn discovery_snapshot(&self) -> Result<WordPressDiscoverySnapshot, WordPressReviewError> {
759        let state = self
760            .0
761            .lock()
762            .unwrap_or_else(|poisoned| poisoned.into_inner());
763        if state.signal_limit_exceeded {
764            return Err(WordPressReviewError::SignalLimitExceeded);
765        }
766        if state.discovery_candidate_identity_limit_exceeded {
767            return Err(WordPressReviewError::DiscoveryCandidateIdentityLimitExceeded);
768        }
769        if state
770            .page_scope
771            .as_ref()
772            .is_some_and(|page_scope| page_scope.selection.candidate_identity_limit_exceeded)
773        {
774            return Err(WordPressReviewError::DiscoveryCandidateIdentityLimitExceeded);
775        }
776        let mut seeds = state.discovery_seeds.clone();
777        seeds.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
778        let omitted_candidate_count = u64::try_from(
779            state
780                .discovery_candidate_fingerprints
781                .len()
782                .saturating_sub(seeds.len()),
783        )
784        .map_err(|_| WordPressReviewError::DiscoveryCandidateIdentityLimitExceeded)?;
785        Ok((
786            seeds,
787            state.discovery_seed_evidence_ids.iter().cloned().collect(),
788            omitted_candidate_count,
789            state.discovery_candidate_fingerprints.clone(),
790            state.discovery_layout.clone(),
791        ))
792    }
793
794    fn finish_page_scope(&self) -> Result<Option<WordPressPageScopeAudit>, WordPressReviewError> {
795        let mut state = self
796            .0
797            .lock()
798            .unwrap_or_else(|poisoned| poisoned.into_inner());
799        let Some(page_scope) = state.page_scope.as_mut() else {
800            return Ok(None);
801        };
802        if page_scope.pending_entry.is_some() || !page_scope.pending.is_empty() {
803            return Err(WordPressReviewError::SignalLimitExceeded);
804        }
805        let mut pages_by_reference = page_scope
806            .pages
807            .drain(..)
808            .map(|page| (page.page_reference.clone(), page))
809            .collect::<BTreeMap<_, _>>();
810        let mut pages = Vec::with_capacity(page_scope.selection.candidates.len());
811        for candidate in &page_scope.selection.candidates {
812            pages.push(
813                pages_by_reference
814                    .remove(&candidate.page_reference)
815                    .unwrap_or(WordPressPageAudit {
816                        page_reference: candidate.page_reference.clone(),
817                        acquisition: WordPressPageAcquisition::NotObserved,
818                        association: WordPressPageAssociation::NotEstablished,
819                        outcome: WordPressPageOutcome::NotObserved,
820                        request_attempted: false,
821                        interpreted_response_bytes: 0,
822                        response_bytes: 0,
823                        evidence_ids: Vec::new(),
824                    }),
825            );
826        }
827        if !pages_by_reference.is_empty() {
828            return Err(WordPressReviewError::SignalLimitExceeded);
829        }
830        let interpreted_response_bytes = pages.iter().try_fold(0_u64, |total, page| {
831            total.checked_add(page.interpreted_response_bytes)
832        });
833        let response_bytes = pages
834            .iter()
835            .try_fold(0_u64, |total, page| total.checked_add(page.response_bytes));
836        let count =
837            |predicate: fn(&WordPressPageAudit) -> bool| -> Result<u8, WordPressReviewError> {
838                u8::try_from(pages.iter().filter(|page| predicate(page)).count())
839                    .map_err(|_| WordPressReviewError::SignalLimitExceeded)
840            };
841        let audit = WordPressPageScopeAudit {
842            mode: page_scope.mode,
843            entry_page_reference: page_scope.entry_page_reference.clone(),
844            candidate_count: page_scope.selection.candidate_count,
845            selected_count: u8::try_from(pages.len())
846                .map_err(|_| WordPressReviewError::SignalLimitExceeded)?,
847            omitted_candidate_count: page_scope.selection.omitted_candidate_count,
848            reused_response_count: count(|page| {
849                page.acquisition == WordPressPageAcquisition::Reused
850            })?,
851            fetched_response_count: count(|page| {
852                page.acquisition == WordPressPageAcquisition::Fetched
853            })?,
854            not_observed_count: count(|page| {
855                page.acquisition == WordPressPageAcquisition::NotObserved
856            })?,
857            rejected_response_count: count(|page| {
858                page.association == WordPressPageAssociation::Rejected
859            })?,
860            accepted_association_count: count(|page| {
861                page.association == WordPressPageAssociation::Accepted
862            })?,
863            rejected_association_count: count(|page| {
864                page.association == WordPressPageAssociation::Rejected
865            })?,
866            attempted_request_count: count(|page| page.request_attempted)?,
867            completed_response_count: count(|page| {
868                page.acquisition != WordPressPageAcquisition::NotObserved
869            })?,
870            committed_response_count: u8::try_from(
871                pages
872                    .iter()
873                    .filter(|page| !page.evidence_ids.is_empty())
874                    .count(),
875            )
876            .map_err(|_| WordPressReviewError::SignalLimitExceeded)?,
877            interpreted_response_bytes: interpreted_response_bytes
878                .ok_or(WordPressReviewError::SignalLimitExceeded)?,
879            response_bytes: response_bytes.ok_or(WordPressReviewError::SignalLimitExceeded)?,
880            pages,
881        };
882        (audit.interpreted_response_bytes <= MAX_WORDPRESS_PAGE_INTERPRETED_BYTES)
883            .then_some(Some(audit))
884            .ok_or(WordPressReviewError::SignalLimitExceeded)
885    }
886
887    #[cfg(feature = "supplied-session-review")]
888    fn supplied_session_observation_binding(
889        &self,
890        root_subject: EntityId,
891    ) -> Option<WordPressSuppliedSessionObservationBinding> {
892        let state = self
893            .0
894            .lock()
895            .unwrap_or_else(|poisoned| poisoned.into_inner());
896        let session = state.supplied_session.as_ref()?;
897        (!session.failed).then_some(())?;
898        let frozen_layout = session.frozen_layout.clone()?;
899        Some(WordPressSuppliedSessionObservationBinding {
900            collector: self.clone(),
901            frozen_layout,
902            root_subject,
903        })
904    }
905
906    #[cfg(feature = "supplied-session-review")]
907    fn begin_supplied_session_observation(
908        &self,
909        policy: &SuppliedSessionPolicy,
910        resources: &[(SuppliedSessionRequestDescriptor, String)],
911    ) -> Result<(), ()> {
912        let mut state = self
913            .0
914            .lock()
915            .unwrap_or_else(|poisoned| poisoned.into_inner());
916        let session = state.supplied_session.as_mut().ok_or(())?;
917        let acquisition = match policy.version() {
918            SuppliedSessionPolicyVersion::V1 | SuppliedSessionPolicyVersion::V2 => {
919                WordPressSuppliedSessionPageAcquisition::Ready
920            },
921            SuppliedSessionPolicyVersion::V3 => WordPressSuppliedSessionPageAcquisition::FormLogin,
922            _ => return Err(()),
923        };
924        if session.failed
925            || session.frozen_layout.is_none()
926            || session.policy_reference.is_some()
927            || !session.pages.is_empty()
928            || !session.pending.is_empty()
929        {
930            session.failed = true;
931            return Err(());
932        }
933        session.policy_reference = Some(policy.policy_reference().to_owned());
934        session.acquisition = Some(acquisition);
935        session.epoch = if acquisition == WordPressSuppliedSessionPageAcquisition::FormLogin {
936            0
937        } else {
938            1
939        };
940        session.application_reference = Some(policy.application_reference().to_owned());
941        session.principal_reference = Some(policy.principal_reference().to_owned());
942        session.credential_mechanism = Some(match policy.credential_mechanism() {
943            SuppliedSessionCredentialMechanism::AuthorizationHeader => {
944                WordPressSuppliedSessionCredentialMechanism::AuthorizationHeader
945            },
946            SuppliedSessionCredentialMechanism::CookieJar => {
947                WordPressSuppliedSessionCredentialMechanism::CookieJar
948            },
949        });
950        for (index, (descriptor, reference)) in resources.iter().enumerate() {
951            let sequence = u8::try_from(index).map_err(|_| ())?;
952            if !descriptor.validate()
953                || descriptor.target_reference() != reference
954                || session.pages.contains_key(&sequence)
955            {
956                session.failed = true;
957                return Err(());
958            }
959            session.pages.insert(
960                sequence,
961                WordPressSuppliedSessionPageAudit {
962                    context_page_reference: supplied_session_page_reference(
963                        policy,
964                        descriptor.target(),
965                        reference,
966                        sequence,
967                    ),
968                    resource_reference: reference.clone(),
969                    resource_evidence_reference: None,
970                    association: WordPressPageAssociation::NotEstablished,
971                    outcome: WordPressSuppliedSessionPageOutcome::NotEvaluated,
972                    interpreted_response_bytes: 0,
973                    evidence_ids: Vec::new(),
974                },
975            );
976        }
977        Ok(())
978    }
979
980    #[cfg(feature = "supplied-session-review")]
981    fn stage_supplied_session_response(
982        &self,
983        sequence: u8,
984        policy: &SuppliedSessionPolicy,
985        descriptor: &SuppliedSessionRequestDescriptor,
986        response: &CollectedHttpResponse,
987    ) -> Result<(), ()> {
988        let (frozen, page) = {
989            let state = self
990                .0
991                .lock()
992                .unwrap_or_else(|poisoned| poisoned.into_inner());
993            let session = state.supplied_session.as_ref().ok_or(())?;
994            let row = session.pages.get(&sequence).ok_or(())?;
995            if session.failed
996                || session.policy_reference.as_deref() != Some(policy.policy_reference())
997                || session.application_reference.as_deref() != Some(policy.application_reference())
998                || row.resource_reference != descriptor.target_reference()
999                || session.pending.contains_key(&sequence)
1000            {
1001                return Err(());
1002            }
1003            (
1004                session.frozen_layout.clone().ok_or(())?,
1005                WordPressPageCandidate {
1006                    url: descriptor.target().clone(),
1007                    page_reference: row.context_page_reference.clone(),
1008                },
1009            )
1010        };
1011        let interpretation = if response.normalized_media_type().as_deref() != Some("text/html") {
1012            PendingSuppliedSessionInterpretation::UnsupportedContent
1013        } else {
1014            match std::str::from_utf8(response.body()) {
1015                Ok(html) => PendingSuppliedSessionInterpretation::Parsed(Box::new(
1016                    extract_wordpress_page_observation_with_asset_fingerprints(
1017                        page,
1018                        &frozen,
1019                        html,
1020                        Vec::new(),
1021                        false,
1022                    ),
1023                )),
1024                Err(_) => PendingSuppliedSessionInterpretation::InvalidUtf8,
1025            }
1026        };
1027        let mut state = self
1028            .0
1029            .lock()
1030            .unwrap_or_else(|poisoned| poisoned.into_inner());
1031        let session = state.supplied_session.as_mut().ok_or(())?;
1032        if session.pending.insert(sequence, interpretation).is_some() {
1033            session.failed = true;
1034            return Err(());
1035        }
1036        Ok(())
1037    }
1038
1039    #[cfg(feature = "supplied-session-review")]
1040    fn commit_supplied_session_response(
1041        &self,
1042        sequence: u8,
1043        policy: &SuppliedSessionPolicy,
1044        descriptor: &SuppliedSessionRequestDescriptor,
1045        receipt: &CommittedSuppliedSessionResourceReceipt,
1046        knowledge: &KnowledgeBase,
1047        root_subject: &EntityId,
1048    ) -> Result<(), ()> {
1049        if !receipt.is_valid_for(sequence, policy, descriptor, knowledge)
1050            || receipt.sequence() != sequence
1051            || receipt.resource_reference() != descriptor.target_reference()
1052        {
1053            self.fail_supplied_session_observation();
1054            return Err(());
1055        }
1056        let (interpretation, context_page_reference) = {
1057            let mut state = self
1058                .0
1059                .lock()
1060                .unwrap_or_else(|poisoned| poisoned.into_inner());
1061            let session = state.supplied_session.as_mut().ok_or(())?;
1062            if session.failed
1063                || session.policy_reference.as_deref() != Some(policy.policy_reference())
1064                || session.application_reference.as_deref() != Some(policy.application_reference())
1065                || session.principal_reference.as_deref() != Some(policy.principal_reference())
1066            {
1067                session.failed = true;
1068                return Err(());
1069            }
1070            let row = session.pages.get_mut(&sequence).ok_or(())?;
1071            if row.resource_reference != descriptor.target_reference()
1072                || row.resource_evidence_reference.is_some()
1073            {
1074                session.failed = true;
1075                return Err(());
1076            }
1077            row.resource_evidence_reference = Some(receipt.evidence_reference().to_owned());
1078            let context_page_reference = row.context_page_reference.clone();
1079            let interpretation = session.pending.remove(&sequence).ok_or(())?;
1080            (interpretation, context_page_reference)
1081        };
1082        let PendingSuppliedSessionInterpretation::Parsed(pending) = interpretation else {
1083            let outcome = match interpretation {
1084                PendingSuppliedSessionInterpretation::UnsupportedContent => {
1085                    WordPressSuppliedSessionPageOutcome::UnsupportedContent
1086                },
1087                PendingSuppliedSessionInterpretation::InvalidUtf8 => {
1088                    WordPressSuppliedSessionPageOutcome::InvalidUtf8
1089                },
1090                PendingSuppliedSessionInterpretation::Parsed(_) => unreachable!(),
1091            };
1092            self.update_supplied_session_page(sequence, outcome, 0, None)?;
1093            return Ok(());
1094        };
1095        let mut pending = *pending;
1096        if !pending.asset_fingerprint_candidates.is_empty()
1097            || pending.asset_fingerprint_candidate_limit_exceeded
1098        {
1099            self.fail_supplied_session_observation();
1100            return Err(());
1101        }
1102        let evidence = supplied_session_page_observation_evidence(
1103            root_subject,
1104            &context_page_reference,
1105            policy,
1106            descriptor.target_reference(),
1107            receipt.evidence_reference(),
1108        )?;
1109        let evidence_id = evidence.id().clone();
1110        knowledge
1111            .insert_evidence_batch(vec![evidence])
1112            .map_err(|_| ())?;
1113        for seed in &mut pending.discovery.seeds {
1114            *seed = seed
1115                .clone()
1116                .without_source_page_references()
1117                .with_source_evidence(evidence_id.clone())
1118                .with_source_supplied_session_page_reference(context_page_reference.clone());
1119        }
1120        let outcome = supplied_session_outcome(pending.outcome);
1121        let interpreted_response_bytes = pending.interpreted_response_bytes;
1122        let association = pending.association;
1123        self.record(pending.signals, pending.discovery, [evidence_id.clone()]);
1124        if association != outcome.association() {
1125            self.fail_supplied_session_observation();
1126            return Err(());
1127        }
1128        self.update_supplied_session_page(
1129            sequence,
1130            outcome,
1131            interpreted_response_bytes,
1132            Some(evidence_id),
1133        )
1134    }
1135
1136    #[cfg(feature = "supplied-session-review")]
1137    fn update_supplied_session_page(
1138        &self,
1139        sequence: u8,
1140        outcome: WordPressSuppliedSessionPageOutcome,
1141        interpreted_response_bytes: u64,
1142        evidence_id: Option<EvidenceId>,
1143    ) -> Result<(), ()> {
1144        let mut state = self
1145            .0
1146            .lock()
1147            .unwrap_or_else(|poisoned| poisoned.into_inner());
1148        let session = state.supplied_session.as_mut().ok_or(())?;
1149        let row = session.pages.get_mut(&sequence).ok_or(())?;
1150        row.outcome = outcome;
1151        row.association = outcome.association();
1152        row.interpreted_response_bytes = interpreted_response_bytes;
1153        row.evidence_ids = evidence_id.into_iter().collect();
1154        Ok(())
1155    }
1156
1157    #[cfg(feature = "supplied-session-review")]
1158    fn discard_supplied_session_response(&self, sequence: u8) {
1159        let mut state = self
1160            .0
1161            .lock()
1162            .unwrap_or_else(|poisoned| poisoned.into_inner());
1163        if let Some(session) = state.supplied_session.as_mut() {
1164            session.pending.remove(&sequence);
1165        }
1166    }
1167
1168    fn fail_supplied_session_observation(&self) {
1169        let mut state = self
1170            .0
1171            .lock()
1172            .unwrap_or_else(|poisoned| poisoned.into_inner());
1173        if let Some(session) = state.supplied_session.as_mut() {
1174            session.failed = true;
1175        }
1176    }
1177
1178    #[cfg(feature = "supplied-session-review")]
1179    fn complete_supplied_session_observation(
1180        &self,
1181        audit: &WebAssessmentSuppliedSessionAudit,
1182    ) -> Result<(), ()> {
1183        let mut state = self
1184            .0
1185            .lock()
1186            .unwrap_or_else(|poisoned| poisoned.into_inner());
1187        let session = state.supplied_session.as_mut().ok_or(())?;
1188        if session.failed
1189            || session.policy_reference.as_deref() != Some(audit.policy_reference())
1190            || session.application_reference.as_deref() != Some(audit.application_reference())
1191            || session.principal_reference.as_deref() != Some(audit.principal_reference())
1192            || usize::from(audit.selected_resource_count()) != session.pages.len()
1193        {
1194            session.failed = true;
1195            return Err(());
1196        }
1197        let epoch = match (session.acquisition, audit.schema()) {
1198            (
1199                Some(WordPressSuppliedSessionPageAcquisition::Ready),
1200                "security.supplied-session-audit/v1" | "security.supplied-session-audit/v2",
1201            ) => 1,
1202            (
1203                Some(WordPressSuppliedSessionPageAcquisition::FormLogin),
1204                "security.supplied-session-audit/v3",
1205            ) => audit.cookie_lifecycle().ok_or(())?.final_epoch(),
1206            _ => return Err(()),
1207        };
1208        if epoch > 1
1209            || (epoch == 0
1210                && (audit.dispatched_resource_count() != 0
1211                    || audit.committed_resource_count() != 0
1212                    || session.pages.values().any(|page| {
1213                        page.resource_evidence_reference.is_some()
1214                            || !page.evidence_ids.is_empty()
1215                            || page.interpreted_response_bytes != 0
1216                            || page.outcome != WordPressSuppliedSessionPageOutcome::NotEvaluated
1217                    })))
1218        {
1219            session.failed = true;
1220            return Err(());
1221        }
1222        session.epoch = epoch;
1223        Ok(())
1224    }
1225
1226    fn finish_supplied_session_pages(
1227        &self,
1228    ) -> Result<Option<WordPressSuppliedSessionPageScopeAudit>, WordPressReviewError> {
1229        let mut state = self
1230            .0
1231            .lock()
1232            .unwrap_or_else(|poisoned| poisoned.into_inner());
1233        let Some(session) = state.supplied_session.as_mut() else {
1234            return Ok(None);
1235        };
1236        if session.failed
1237            || session.pending_entry_layout.is_some()
1238            || session.frozen_layout.is_none()
1239            || !session.pending.is_empty()
1240        {
1241            return Err(WordPressReviewError::SignalLimitExceeded);
1242        }
1243        let pages = std::mem::take(&mut session.pages)
1244            .into_values()
1245            .collect::<Vec<_>>();
1246        let count = |predicate: fn(&WordPressSuppliedSessionPageAudit) -> bool| {
1247            u8::try_from(pages.iter().filter(|page| predicate(page)).count())
1248                .map_err(|_| WordPressReviewError::SignalLimitExceeded)
1249        };
1250        let interpreted_response_bytes = pages.iter().try_fold(0_u64, |total, page| {
1251            total.checked_add(page.interpreted_response_bytes)
1252        });
1253        Ok(Some(WordPressSuppliedSessionPageScopeAudit {
1254            acquisition: session
1255                .acquisition
1256                .ok_or(WordPressReviewError::SignalLimitExceeded)?,
1257            policy_reference: session
1258                .policy_reference
1259                .take()
1260                .ok_or(WordPressReviewError::SignalLimitExceeded)?,
1261            application_reference: session
1262                .application_reference
1263                .take()
1264                .ok_or(WordPressReviewError::SignalLimitExceeded)?,
1265            principal_reference: session
1266                .principal_reference
1267                .take()
1268                .ok_or(WordPressReviewError::SignalLimitExceeded)?,
1269            credential_mechanism: session
1270                .credential_mechanism
1271                .take()
1272                .ok_or(WordPressReviewError::SignalLimitExceeded)?,
1273            epoch: session.epoch,
1274            selected_count: u8::try_from(pages.len())
1275                .map_err(|_| WordPressReviewError::SignalLimitExceeded)?,
1276            committed_count: count(|page| page.resource_evidence_reference.is_some())?,
1277            accepted_count: count(|page| page.association == WordPressPageAssociation::Accepted)?,
1278            rejected_count: count(|page| page.association == WordPressPageAssociation::Rejected)?,
1279            not_established_count: count(|page| {
1280                page.association == WordPressPageAssociation::NotEstablished
1281            })?,
1282            not_evaluated_count: count(|page| {
1283                matches!(
1284                    page.outcome,
1285                    WordPressSuppliedSessionPageOutcome::UnsupportedContent
1286                        | WordPressSuppliedSessionPageOutcome::InvalidUtf8
1287                        | WordPressSuppliedSessionPageOutcome::NotEvaluated
1288                )
1289            })?,
1290            interpreted_response_bytes: interpreted_response_bytes
1291                .ok_or(WordPressReviewError::SignalLimitExceeded)?,
1292            pages,
1293        }))
1294    }
1295
1296    fn record_discovery(&self, signals: impl IntoIterator<Item = WordPressComponentSignal>) {
1297        let mut state = self
1298            .0
1299            .lock()
1300            .unwrap_or_else(|poisoned| poisoned.into_inner());
1301        for signal in signals {
1302            if state.signals.contains(&signal) {
1303                continue;
1304            }
1305            if state.signals.len() == MAX_WORDPRESS_SIGNALS {
1306                state.signal_limit_exceeded = true;
1307                break;
1308            }
1309            state.signals.push(signal);
1310        }
1311    }
1312}
1313
1314#[cfg(feature = "supplied-session-review")]
1315pub(super) struct WordPressSuppliedSessionObservationBinding {
1316    collector: WordPressSignalCollector,
1317    frozen_layout: FrozenWordPressPageLayout,
1318    root_subject: EntityId,
1319}
1320
1321#[cfg(feature = "supplied-session-review")]
1322impl WordPressSuppliedSessionObservationBinding {
1323    pub(super) fn complete(&self, audit: &WebAssessmentSuppliedSessionAudit) -> Result<(), ()> {
1324        self.collector.complete_supplied_session_observation(audit)
1325    }
1326}
1327
1328#[cfg(feature = "supplied-session-review")]
1329impl SuppliedSessionCommittedResourceObserver for WordPressSuppliedSessionObservationBinding {
1330    fn begin(
1331        &self,
1332        policy: &SuppliedSessionPolicy,
1333        resources: &[(SuppliedSessionRequestDescriptor, String)],
1334    ) -> Result<(), ()> {
1335        let frozen_is_exact = self.frozen_layout.application_url.as_ref()
1336            == Some(policy.application_url())
1337            && (self.frozen_layout.themes.is_some() || self.frozen_layout.plugins.is_some());
1338        if !frozen_is_exact {
1339            self.collector.fail_supplied_session_observation();
1340            return Err(());
1341        }
1342        self.collector
1343            .begin_supplied_session_observation(policy, resources)
1344    }
1345
1346    fn stage_response(
1347        &self,
1348        sequence: u8,
1349        policy: &SuppliedSessionPolicy,
1350        descriptor: &SuppliedSessionRequestDescriptor,
1351        response: &CollectedHttpResponse,
1352    ) -> Result<(), ()> {
1353        self.collector
1354            .stage_supplied_session_response(sequence, policy, descriptor, response)
1355    }
1356
1357    fn commit_response(
1358        &self,
1359        sequence: u8,
1360        policy: &SuppliedSessionPolicy,
1361        descriptor: &SuppliedSessionRequestDescriptor,
1362        receipt: &CommittedSuppliedSessionResourceReceipt,
1363        knowledge: &KnowledgeBase,
1364    ) -> Result<(), ()> {
1365        self.collector.commit_supplied_session_response(
1366            sequence,
1367            policy,
1368            descriptor,
1369            receipt,
1370            knowledge,
1371            &self.root_subject,
1372        )
1373    }
1374
1375    fn discard_response(&self, sequence: u8) {
1376        self.collector.discard_supplied_session_response(sequence);
1377    }
1378
1379    fn fail(&self) {
1380        self.collector.fail_supplied_session_observation();
1381    }
1382}
1383
1384#[cfg(feature = "supplied-session-review")]
1385fn supplied_session_page_reference(
1386    policy: &SuppliedSessionPolicy,
1387    target: &Url,
1388    resource_reference: &str,
1389    sequence: u8,
1390) -> String {
1391    let mut digest = sha2::Sha256::new();
1392    for value in [
1393        b"security.wordpress-supplied-session-page.v1".as_slice(),
1394        policy.policy_reference().as_bytes(),
1395        policy.application_reference().as_bytes(),
1396        policy.principal_reference().as_bytes(),
1397        target.as_str().as_bytes(),
1398        resource_reference.as_bytes(),
1399        &[sequence],
1400    ] {
1401        digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
1402        digest.update(value);
1403    }
1404    format!("sha256:{:x}", digest.finalize())
1405}
1406
1407#[cfg(feature = "supplied-session-review")]
1408fn supplied_session_outcome(outcome: WordPressPageOutcome) -> WordPressSuppliedSessionPageOutcome {
1409    match outcome {
1410        WordPressPageOutcome::Accepted => WordPressSuppliedSessionPageOutcome::Accepted,
1411        WordPressPageOutcome::NoComponentSignals => {
1412            WordPressSuppliedSessionPageOutcome::NoComponentSignals
1413        },
1414        WordPressPageOutcome::IncompatibleApplication => {
1415            WordPressSuppliedSessionPageOutcome::IncompatibleApplication
1416        },
1417        WordPressPageOutcome::Soft404 => WordPressSuppliedSessionPageOutcome::Soft404,
1418        WordPressPageOutcome::LoginResponse => WordPressSuppliedSessionPageOutcome::LoginResponse,
1419        WordPressPageOutcome::Truncated => WordPressSuppliedSessionPageOutcome::Truncated,
1420        WordPressPageOutcome::UnsupportedContent
1421        | WordPressPageOutcome::Malformed
1422        | WordPressPageOutcome::NotObserved
1423        | WordPressPageOutcome::RequestFailed
1424        | WordPressPageOutcome::RateLimited
1425        | WordPressPageOutcome::BudgetExhausted
1426        | WordPressPageOutcome::Cancelled
1427        | WordPressPageOutcome::DeadlineExceeded => {
1428            WordPressSuppliedSessionPageOutcome::NotEvaluated
1429        },
1430    }
1431}
1432
1433#[cfg(feature = "supplied-session-review")]
1434fn supplied_session_page_observation_evidence(
1435    root_subject: &EntityId,
1436    context_page_reference: &str,
1437    policy: &SuppliedSessionPolicy,
1438    resource_reference: &str,
1439    resource_evidence_reference: &str,
1440) -> Result<Evidence, ()> {
1441    let predicate = KnowledgePredicate::new(
1442        "web.wordpress-supplied-session-discovery",
1443        "health-qualified-page-representation-observed",
1444    )
1445    .map_err(|_| ())?;
1446    let source = EvidenceSource::new(
1447        "wordpress.supplied-session-page-observation",
1448        "committed-health-qualified-html-response",
1449    )
1450    .and_then(|source| source.with_correlation_id(context_page_reference.to_owned()))
1451    .map_err(|_| ())?;
1452    let mut digest = sha2::Sha256::new();
1453    for value in [
1454        context_page_reference.as_bytes(),
1455        policy.policy_reference().as_bytes(),
1456        policy.application_reference().as_bytes(),
1457        policy.principal_reference().as_bytes(),
1458        resource_reference.as_bytes(),
1459        resource_evidence_reference.as_bytes(),
1460        b"epoch=1",
1461    ] {
1462        digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
1463        digest.update(value);
1464    }
1465    Ok(Evidence::new(
1466        root_subject.clone(),
1467        EvidenceKind::Content,
1468        predicate,
1469        EvidenceValue::Text(format!(
1470            "{context_page_reference}:sha256:{:x}",
1471            digest.finalize()
1472        )),
1473        source,
1474        ConfidenceScore::from_percent(70).map_err(|_| ())?,
1475    ))
1476}
1477
1478fn committed_parent_evidence_is_valid(
1479    knowledge: &KnowledgeBase,
1480    evidence_ids: &[EvidenceId],
1481    expected_subject: &EntityId,
1482    expected_url: &Url,
1483) -> bool {
1484    let exact_final_url = evidence_ids.iter().any(|id| {
1485        knowledge
1486            .inspect_evidence(id, |evidence| {
1487                evidence.predicate() == &HttpEvidencePredicate::RESPONSE_FINAL_URL.into_knowledge()
1488                    && evidence.value() == &EvidenceValue::Text(expected_url.to_string())
1489            })
1490            .unwrap_or(false)
1491    });
1492    let exact_request_url = evidence_ids.iter().any(|id| {
1493        knowledge
1494            .inspect_evidence(id, |evidence| {
1495                evidence.predicate() == &HttpEvidencePredicate::REQUEST_URL.into_knowledge()
1496                    && evidence.value() == &EvidenceValue::Text(expected_url.to_string())
1497            })
1498            .unwrap_or(false)
1499    });
1500    exact_final_url
1501        && exact_request_url
1502        && !evidence_ids.is_empty()
1503        && evidence_ids.len() <= 8
1504        && evidence_ids.iter().collect::<BTreeSet<_>>().len() == evidence_ids.len()
1505        && evidence_ids.iter().all(|id| {
1506            knowledge
1507                .inspect_evidence(id, |evidence| evidence.subject() == expected_subject)
1508                .unwrap_or(false)
1509        })
1510}
1511
1512fn root_page_observation_evidence(
1513    root_subject: &EntityId,
1514    parents: &[EvidenceId],
1515    page_reference: &str,
1516) -> Result<Evidence, WordPressReviewError> {
1517    let predicate = KnowledgePredicate::new(
1518        "web.wordpress-page-discovery",
1519        "page-representation-observed",
1520    )
1521    .map_err(|_| WordPressReviewError::SignalLimitExceeded)?;
1522    let mut binding_digest = sha2::Sha256::new();
1523    binding_digest.update(page_reference.as_bytes());
1524    for parent in parents {
1525        binding_digest.update([0]);
1526        binding_digest.update(parent.to_string().as_bytes());
1527    }
1528    let source = EvidenceSource::new("wordpress.page-observation", "committed-html-response")
1529        .and_then(|source| source.with_correlation_id(page_reference.to_owned()))
1530        .map_err(|_| WordPressReviewError::SignalLimitExceeded)?;
1531    let reliability =
1532        ConfidenceScore::from_percent(70).map_err(|_| WordPressReviewError::SignalLimitExceeded)?;
1533    // The committed response evidence belongs to the secondary page subject,
1534    // while the existing WordPress discovery item belongs to the application
1535    // root. Knowledge derivations intentionally reject cross-subject parents,
1536    // so this sealed post-commit binding is a direct root-scoped fact. It is
1537    // minted only after the exact request/final URL and subject were verified.
1538    Ok(Evidence::new(
1539        root_subject.clone(),
1540        EvidenceKind::Content,
1541        predicate,
1542        EvidenceValue::Text(format!(
1543            "{page_reference}:sha256:{:x}",
1544            binding_digest.finalize()
1545        )),
1546        source,
1547        reliability,
1548    ))
1549}
1550
1551pub(super) struct WordPressReviewBinding {
1552    inputs: WordPressReviewInputs,
1553    collector: WordPressSignalCollector,
1554    discovery_enabled: bool,
1555    discovery_audit: Option<WebAssessmentWordPressDiscoveryAudit>,
1556    discovery_context: WordPressDiscoveryContext,
1557    page_scope: Option<WordPressPageScope>,
1558    asset_fingerprint_collector: Option<WordPressAssetFingerprintCollector>,
1559    asset_fingerprint_audit: Option<WordPressAssetFingerprintExecution>,
1560    supplied_session_selected: bool,
1561}
1562
1563#[derive(Debug)]
1564pub(super) enum WordPressReviewFinishError {
1565    DiscoveryNotExecuted,
1566    Review,
1567}
1568
1569impl WordPressReviewBinding {
1570    pub(super) fn new(
1571        inputs: WordPressReviewInputs,
1572        discovery_enabled: bool,
1573        page_scope: Option<WordPressPageScope>,
1574        application_url: Url,
1575        supplied_session_selected: bool,
1576    ) -> Self {
1577        let collector =
1578            WordPressSignalCollector::new(page_scope, &application_url, supplied_session_selected);
1579        let asset_fingerprint_collector = inputs
1580            .shared_asset_fingerprint_catalog()
1581            .map(WordPressAssetFingerprintCollector::new);
1582        let discovery_context =
1583            WordPressDiscoveryContext::new(application_url, inputs.discovery_layout().cloned());
1584        Self {
1585            inputs,
1586            collector,
1587            discovery_enabled,
1588            discovery_audit: None,
1589            discovery_context,
1590            page_scope,
1591            asset_fingerprint_collector,
1592            asset_fingerprint_audit: None,
1593            supplied_session_selected,
1594        }
1595    }
1596
1597    pub(super) fn collector(&self) -> WordPressSignalCollector {
1598        self.collector.clone()
1599    }
1600
1601    pub(super) const fn discovery_enabled(&self) -> bool {
1602        self.discovery_enabled
1603    }
1604
1605    pub(super) const fn page_scope(&self) -> Option<WordPressPageScope> {
1606        self.page_scope
1607    }
1608
1609    pub(super) const fn asset_fingerprints_enabled(&self) -> bool {
1610        self.asset_fingerprint_collector.is_some()
1611    }
1612
1613    pub(super) fn asset_fingerprint_collector(&self) -> Option<WordPressAssetFingerprintCollector> {
1614        self.asset_fingerprint_collector.clone()
1615    }
1616
1617    pub(super) fn discovery_context(&self) -> WordPressDiscoveryContext {
1618        self.discovery_context.clone()
1619    }
1620
1621    pub(super) const fn supplied_session_selected(&self) -> bool {
1622        self.supplied_session_selected
1623    }
1624
1625    #[cfg(feature = "supplied-session-review")]
1626    pub(super) fn supplied_session_observation_binding(
1627        &self,
1628        root_subject: EntityId,
1629    ) -> Option<WordPressSuppliedSessionObservationBinding> {
1630        self.collector
1631            .supplied_session_observation_binding(root_subject)
1632    }
1633
1634    pub(super) async fn execute_discovery(
1635        &mut self,
1636        subject: EntityId,
1637        authority: &super::SharedWebRuntimeAuthority,
1638        deadline: Option<tokio::time::Instant>,
1639    ) -> Result<WordPressDiscoveryStop, WordPressDiscoveryExecutionError> {
1640        if !self.discovery_enabled {
1641            return Ok(WordPressDiscoveryStop::Complete);
1642        }
1643        let (seeds, root_evidence_ids, omitted_candidate_count, candidate_fingerprints, layout) =
1644            self.collector
1645                .discovery_snapshot()
1646                .map_err(|error| match error {
1647                    WordPressReviewError::DiscoveryCandidateIdentityLimitExceeded => {
1648                        WordPressDiscoveryExecutionError::CandidateIdentityLimitExceeded
1649                    },
1650                    _ => WordPressDiscoveryExecutionError::EvidenceModel,
1651                })?;
1652        let mut execution = execute_wordpress_discovery(
1653            WordPressDiscoveryExecutionInput::new(
1654                seeds,
1655                omitted_candidate_count,
1656                candidate_fingerprints,
1657                root_evidence_ids,
1658                subject,
1659                layout.unwrap_or_else(|| self.discovery_context.unresolved_audit()),
1660            ),
1661            authority,
1662            deadline,
1663        )
1664        .await?;
1665        self.collector.record_discovery(execution.signals);
1666        execution.audit.page_scope = self
1667            .collector
1668            .finish_page_scope()
1669            .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?;
1670        execution.audit.supplied_session_pages = self
1671            .collector
1672            .finish_supplied_session_pages()
1673            .map_err(|_| WordPressDiscoveryExecutionError::EvidenceModel)?;
1674        let stop = execution.stop;
1675        self.discovery_audit = Some(execution.audit);
1676        Ok(stop)
1677    }
1678
1679    pub(super) async fn execute_asset_fingerprints(
1680        &mut self,
1681        subject: EntityId,
1682        authority: &super::SharedWebRuntimeAuthority,
1683        deadline: Option<tokio::time::Instant>,
1684    ) -> Result<WordPressAssetFingerprintStop, WordPressAssetFingerprintRuntimeError> {
1685        let Some(collector) = self.asset_fingerprint_collector.as_ref() else {
1686            return Ok(WordPressAssetFingerprintStop::Complete);
1687        };
1688        if self.asset_fingerprint_audit.is_some() {
1689            return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
1690        }
1691        let discovery = self
1692            .discovery_audit
1693            .as_ref()
1694            .ok_or(WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1695        let page_attempts = discovery
1696            .page_scope()
1697            .map_or(0, WordPressPageScopeAudit::attempted_request_count);
1698        let page_bytes = discovery
1699            .page_scope()
1700            .map_or(0, WordPressPageScopeAudit::response_bytes);
1701        let prior_attempts = discovery
1702            .attempted_request_count()
1703            .checked_add(page_attempts)
1704            .ok_or(WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1705        let prior_response_bytes = discovery
1706            .response_bytes()
1707            .checked_add(page_bytes)
1708            .ok_or(WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1709        let allowance =
1710            WordPressAssetFingerprintAllowance::new(prior_attempts, prior_response_bytes, true)?;
1711        let execution = execute_wordpress_asset_fingerprints(
1712            collector.plan()?,
1713            authority,
1714            &subject,
1715            allowance,
1716            deadline,
1717        )
1718        .await?;
1719        let stop = execution.stop();
1720        self.asset_fingerprint_audit = Some(execution);
1721        Ok(stop)
1722    }
1723
1724    pub(super) fn finish(
1725        self,
1726        subject: EntityId,
1727    ) -> Result<CommittedWordPressReview, WordPressReviewFinishError> {
1728        if self.discovery_enabled && self.discovery_audit.is_none()
1729            || self.asset_fingerprint_collector.is_some() && self.asset_fingerprint_audit.is_none()
1730        {
1731            return Err(WordPressReviewFinishError::DiscoveryNotExecuted);
1732        }
1733        let (signals, evidence_ids) = self
1734            .collector
1735            .snapshot()
1736            .map_err(|_| WordPressReviewFinishError::Review)?;
1737        let result = evaluate_wordpress_review(&signals, &self.inputs)
1738            .map_err(|_| WordPressReviewFinishError::Review)?;
1739        let audit = WebAssessmentWordPressAudit {
1740            result,
1741            signal_count: u16::try_from(signals.len())
1742                .map_err(|_| WordPressReviewFinishError::Review)?,
1743            evidence_reference_count: u16::try_from(evidence_ids.len())
1744                .map_err(|_| WordPressReviewFinishError::Review)?,
1745            item_projected: !signals.is_empty(),
1746            discovery: self.discovery_audit,
1747            asset_fingerprints: self.asset_fingerprint_audit,
1748        };
1749        Ok(CommittedWordPressReview {
1750            subject,
1751            evidence_ids,
1752            audit,
1753        })
1754    }
1755}
1756
1757/// Redaction-safe, transport-free WordPress interpretation retained by one
1758/// completed assessment. It deliberately has no Serde implementation.
1759#[derive(Clone, Debug, Eq, PartialEq)]
1760pub struct WebAssessmentWordPressAudit {
1761    result: WordPressReviewResult,
1762    signal_count: u16,
1763    evidence_reference_count: u16,
1764    item_projected: bool,
1765    discovery: Option<WebAssessmentWordPressDiscoveryAudit>,
1766    asset_fingerprints: Option<WordPressAssetFingerprintExecution>,
1767}
1768
1769impl WebAssessmentWordPressAudit {
1770    pub const fn result(&self) -> &WordPressReviewResult {
1771        &self.result
1772    }
1773
1774    pub const fn signal_count(&self) -> u16 {
1775        self.signal_count
1776    }
1777
1778    pub const fn evidence_reference_count(&self) -> u16 {
1779        self.evidence_reference_count
1780    }
1781
1782    /// Returns zero for the legacy transport-free review and the exact broker-
1783    /// accounted attempt count for explicitly selected metadata discovery.
1784    pub fn additional_request_count(&self) -> u8 {
1785        let discovery = match &self.discovery {
1786            Some(discovery) => discovery.attempted_request_count().saturating_add(
1787                discovery
1788                    .page_scope()
1789                    .map_or(0, WordPressPageScopeAudit::attempted_request_count),
1790            ),
1791            None => 0,
1792        };
1793        discovery.saturating_add(self.asset_fingerprints.as_ref().map_or(
1794            0,
1795            WordPressAssetFingerprintExecution::attempted_request_count,
1796        ))
1797    }
1798
1799    /// Returns the separately versioned, explicitly selected metadata audit.
1800    pub const fn discovery(&self) -> Option<&WebAssessmentWordPressDiscoveryAudit> {
1801        self.discovery.as_ref()
1802    }
1803
1804    /// Returns the explicitly selected finite-catalogue byte-comparison audit.
1805    /// Candidate releases remain conditional matches, never installed versions.
1806    pub const fn asset_fingerprints(&self) -> Option<&WordPressAssetFingerprintExecution> {
1807        self.asset_fingerprints.as_ref()
1808    }
1809
1810    pub const fn item_projected(&self) -> bool {
1811        self.item_projected
1812    }
1813}
1814
1815pub(super) struct CommittedWordPressReview {
1816    subject: EntityId,
1817    evidence_ids: Vec<EvidenceId>,
1818    audit: WebAssessmentWordPressAudit,
1819}
1820
1821impl CommittedWordPressReview {
1822    pub(super) const fn audit(&self) -> &WebAssessmentWordPressAudit {
1823        &self.audit
1824    }
1825}
1826
1827pub(super) fn project_wordpress_item(
1828    context: &mut AssessmentProjectionContext,
1829    knowledge: &KnowledgeBase,
1830    review: &CommittedWordPressReview,
1831) -> Result<(), AssessmentItemProjectionError> {
1832    for evidence_id in &review.evidence_ids {
1833        if !context.has_evidence(evidence_id) {
1834            context.register_evidence(knowledge, evidence_id)?;
1835        }
1836    }
1837    let mut discovery_evidence_ids = review
1838        .audit
1839        .discovery()
1840        .map(|discovery| {
1841            let mut evidence_ids = discovery
1842                .page_scope()
1843                .into_iter()
1844                .flat_map(WordPressPageScopeAudit::pages)
1845                .flat_map(WordPressPageAudit::evidence_ids)
1846                .cloned()
1847                .collect::<Vec<_>>();
1848            evidence_ids.extend(
1849                discovery
1850                    .supplied_session_pages()
1851                    .into_iter()
1852                    .flat_map(WordPressSuppliedSessionPageScopeAudit::pages)
1853                    .flat_map(WordPressSuppliedSessionPageAudit::evidence_ids)
1854                    .cloned(),
1855            );
1856            evidence_ids.extend(
1857                discovery
1858                    .sources()
1859                    .iter()
1860                    .flat_map(WordPressDiscoverySourceAudit::evidence_ids)
1861                    .cloned(),
1862            );
1863            evidence_ids
1864        })
1865        .unwrap_or_default();
1866    if let Some(fingerprints) = review.audit.asset_fingerprints() {
1867        discovery_evidence_ids.extend(
1868            fingerprints
1869                .resources()
1870                .iter()
1871                .flat_map(WordPressAssetFingerprintResourceReceipt::evidence_ids)
1872                .cloned(),
1873        );
1874    }
1875    discovery_evidence_ids.sort();
1876    discovery_evidence_ids.dedup();
1877    for evidence_id in &discovery_evidence_ids {
1878        if !context.has_evidence(evidence_id) {
1879            context.register_evidence(knowledge, evidence_id)?;
1880        }
1881    }
1882    if review.audit.item_projected {
1883        context.project_observation(
1884            &WORDPRESS_REVIEW_CAPABILITY,
1885            knowledge,
1886            &review.subject,
1887            &AssessmentItemTarget::subject(),
1888            &review.evidence_ids,
1889        )?;
1890    }
1891    if !discovery_evidence_ids.is_empty() {
1892        context.project_observation(
1893            &WORDPRESS_DISCOVERY_OBSERVATION_CAPABILITY,
1894            knowledge,
1895            &review.subject,
1896            &AssessmentItemTarget::subject(),
1897            &discovery_evidence_ids,
1898        )?;
1899    }
1900    Ok(())
1901}
1902
1903/// Parses the committed entry representation once, then resolves component
1904/// signals and metadata candidates against the same selected application and
1905/// layout decision. A raw asset reference is never normalized into authority.
1906#[cfg(any(test, fuzzing))]
1907pub(super) fn extract_wordpress_entry_observation(
1908    context: &WordPressDiscoveryContext,
1909    document_url: &Url,
1910    html: &str,
1911    rest_header: &WordPressRestIndexAdvertisement,
1912    discovery_enabled: bool,
1913    page_scope_selected: bool,
1914) -> WordPressEntryObservation {
1915    extract_wordpress_entry_observation_with_asset_fingerprints(
1916        context,
1917        document_url,
1918        html,
1919        rest_header,
1920        discovery_enabled,
1921        page_scope_selected,
1922        false,
1923    )
1924}
1925
1926#[allow(clippy::too_many_arguments)]
1927pub(super) fn extract_wordpress_entry_observation_with_asset_fingerprints(
1928    context: &WordPressDiscoveryContext,
1929    document_url: &Url,
1930    html: &str,
1931    rest_header: &WordPressRestIndexAdvertisement,
1932    discovery_enabled: bool,
1933    page_scope_selected: bool,
1934    asset_fingerprints_selected: bool,
1935) -> WordPressEntryObservation {
1936    let mut selection = WordPressDiscoverySeedSelection {
1937        layout: Some(context.unresolved_audit()),
1938        ..WordPressDiscoverySeedSelection::default()
1939    };
1940    let mut signals = Vec::new();
1941    let mut page_candidate_urls = BTreeMap::<String, Url>::new();
1942    let mut page_candidate_identities = BTreeSet::<[u8; 32]>::new();
1943    let mut page_candidate_identity_limit_exceeded = false;
1944    if document_url != context.application_url() {
1945        return WordPressEntryObservation {
1946            signals,
1947            discovery: selection,
1948            page_candidates: WordPressPageCandidateSelection::default(),
1949            frozen_page_layout: FrozenWordPressPageLayout::default(),
1950            asset_fingerprint_candidates: Vec::new(),
1951            asset_fingerprint_candidate_limit_exceeded: false,
1952        };
1953    }
1954    let dom = parse_document(RcDom::default(), ParseOpts::default()).one(html);
1955    let resolution_base = effective_document_base(&dom.document, document_url);
1956    let mut assets = Vec::new();
1957    let mut fingerprint_references = BTreeMap::<String, ObservedAssetReference>::new();
1958    let mut fingerprint_reference_limit_exceeded = false;
1959    let mut asset_identities = BTreeSet::new();
1960    let mut skipped_foreign_origin_count = 0_u16;
1961    let mut rest_candidates = Vec::new();
1962    let rest_invalid = matches!(
1963        rest_header,
1964        WordPressRestIndexAdvertisement::InvalidOrAmbiguous
1965    );
1966    if let WordPressRestIndexAdvertisement::Candidate(url) = rest_header {
1967        rest_candidates.push(url.clone());
1968    }
1969    let mut pending = vec![dom.document.clone()];
1970    while let Some(handle) = pending.pop() {
1971        if let NodeData::Element { name, attrs, .. } = &handle.data {
1972            if name.ns == ns!(html) {
1973                let local = name.local.as_ref();
1974                if local == "meta"
1975                    && html_attribute(attrs, "name")
1976                        .is_some_and(|value| value.eq_ignore_ascii_case("generator"))
1977                {
1978                    if let Some(content) = html_attribute(attrs, "content") {
1979                        if let Some(version) = wordpress_generator_version(&content) {
1980                            if let Ok(signal) =
1981                                WordPressComponentSignal::generator_metadata(version)
1982                            {
1983                                push_bounded_signal(&mut signals, signal);
1984                            }
1985                        }
1986                    }
1987                }
1988                if local == "link" && link_has_relation(attrs, "https://api.w.org/") {
1989                    if let (Some(reference), Some(base)) =
1990                        (html_attribute(attrs, "href"), resolution_base.as_ref())
1991                    {
1992                        if let Some(url) = projected_rest_candidate(document_url, base, &reference)
1993                        {
1994                            rest_candidates.push(url);
1995                        }
1996                    }
1997                }
1998                if page_scope_selected && local == "a" && !html_has_attribute(attrs, "download") {
1999                    if let (Some(reference), Some(base)) =
2000                        (html_attribute(attrs, "href"), resolution_base.as_ref())
2001                    {
2002                        if let Some(candidate) = eligible_wordpress_page_candidate(
2003                            context,
2004                            document_url,
2005                            base,
2006                            &reference,
2007                        ) {
2008                            let fingerprint = page_url_fingerprint(&candidate);
2009                            if !page_candidate_identities.contains(&fingerprint) {
2010                                if page_candidate_identities.len()
2011                                    == MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
2012                                {
2013                                    page_candidate_identity_limit_exceeded = true;
2014                                } else {
2015                                    page_candidate_identities.insert(fingerprint);
2016                                    page_candidate_urls
2017                                        .insert(candidate.as_str().to_owned(), candidate);
2018                                    if page_candidate_urls.len() > MAX_WORDPRESS_PAGE_CANDIDATES {
2019                                        if let Some(last) =
2020                                            page_candidate_urls.keys().next_back().cloned()
2021                                        {
2022                                            page_candidate_urls.remove(&last);
2023                                        }
2024                                    }
2025                                }
2026                            }
2027                        }
2028                    }
2029                }
2030                let fingerprint_reference = asset_fingerprints_selected
2031                    .then(|| match local {
2032                        "script" => html_attribute(attrs, "src"),
2033                        "link" if link_has_stylesheet_relation(attrs) => {
2034                            html_attribute(attrs, "href")
2035                        },
2036                        _ => None,
2037                    })
2038                    .flatten();
2039                if let (Some(reference), Some(base)) =
2040                    (fingerprint_reference, resolution_base.as_ref())
2041                {
2042                    if crate::http_evidence::wordpress_reference_path_is_safe(&reference) {
2043                        if let Ok(url) = base.join(&reference) {
2044                            if safe_observed_asset_url(document_url, &url)
2045                                && asset_is_layout_candidate(context, &url)
2046                            {
2047                                let identity = url.as_str().to_owned();
2048                                if let Some(existing) = fingerprint_references.get_mut(&identity) {
2049                                    if reference < existing.raw_reference {
2050                                        existing.raw_reference.clone_from(&reference);
2051                                        existing.resolution_base.clone_from(base);
2052                                    }
2053                                } else if fingerprint_references.len()
2054                                    == MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES
2055                                {
2056                                    fingerprint_reference_limit_exceeded = true;
2057                                } else {
2058                                    fingerprint_references.insert(
2059                                        identity,
2060                                        ObservedAssetReference {
2061                                            raw_reference: reference,
2062                                            resolution_base: base.clone(),
2063                                            resolved_url: url,
2064                                        },
2065                                    );
2066                                }
2067                            }
2068                        }
2069                    }
2070                }
2071                let asset = match local {
2072                    "link" if link_relation_can_load_resource(attrs) => {
2073                        html_attribute(attrs, "href")
2074                    },
2075                    "script" | "img" | "source" => html_attribute(attrs, "src"),
2076                    _ => None,
2077                };
2078                if let (Some(reference), Some(base)) = (asset, resolution_base.as_ref()) {
2079                    if !crate::http_evidence::wordpress_reference_path_is_safe(&reference) {
2080                        pending.extend(handle.children.borrow().iter().rev().cloned());
2081                        continue;
2082                    }
2083                    if let Ok(url) = base.join(&reference) {
2084                        if safe_resource_shape(&url) && url.origin() != document_url.origin() {
2085                            skipped_foreign_origin_count =
2086                                skipped_foreign_origin_count.saturating_add(1);
2087                        } else if safe_observed_asset_url(document_url, &url)
2088                            && asset_is_layout_candidate(context, &url)
2089                        {
2090                            let identity = url.as_str().to_owned();
2091                            if asset_identities.insert(identity) {
2092                                if asset_identities.len()
2093                                    > MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
2094                                {
2095                                    selection.candidate_identity_limit_exceeded = true;
2096                                } else {
2097                                    assets.push(url);
2098                                }
2099                            }
2100                        }
2101                    }
2102                }
2103            }
2104        }
2105        pending.extend(handle.children.borrow().iter().rev().cloned());
2106    }
2107
2108    rest_candidates.sort_by(|left, right| left.as_str().cmp(right.as_str()));
2109    rest_candidates.dedup();
2110    if asset_identities.len() > MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES {
2111        signals.retain(|signal| {
2112            signal.source() == crate::wordpress_review::WordPressEvidenceSource::GeneratorMetadata
2113        });
2114        return WordPressEntryObservation {
2115            signals,
2116            discovery: selection,
2117            page_candidates: WordPressPageCandidateSelection::default(),
2118            frozen_page_layout: FrozenWordPressPageLayout::default(),
2119            asset_fingerprint_candidates: Vec::new(),
2120            asset_fingerprint_candidate_limit_exceeded: fingerprint_reference_limit_exceeded,
2121        };
2122    }
2123    resolve_entry_layout(
2124        EntryLayoutResolution {
2125            context,
2126            assets: &assets,
2127            rest_candidates: &rest_candidates,
2128            rest_invalid,
2129            skipped_foreign_origin_count,
2130            discovery_enabled,
2131        },
2132        &mut signals,
2133        &mut selection,
2134    );
2135    let frozen_page_layout = freeze_page_layout(context, &mut selection);
2136    let asset_fingerprint_candidates = if asset_fingerprints_selected {
2137        observed_asset_fingerprint_candidates(
2138            context.application_url(),
2139            document_url,
2140            &frozen_page_layout,
2141            super::wordpress_discovery::opaque_url_reference(
2142                "wordpress-discovery-page",
2143                document_url,
2144            ),
2145            fingerprint_references.into_values(),
2146        )
2147    } else {
2148        Vec::new()
2149    };
2150    let page_candidates = select_wordpress_page_candidates(
2151        page_candidate_urls.into_values(),
2152        page_candidate_identities.len(),
2153        page_candidate_identity_limit_exceeded,
2154    );
2155    WordPressEntryObservation {
2156        signals,
2157        discovery: selection,
2158        page_candidates,
2159        frozen_page_layout,
2160        asset_fingerprint_candidates,
2161        asset_fingerprint_candidate_limit_exceeded: fingerprint_reference_limit_exceeded,
2162    }
2163}
2164
2165pub(super) fn extract_wordpress_page_observation_with_asset_fingerprints(
2166    page: WordPressPageCandidate,
2167    frozen: &FrozenWordPressPageLayout,
2168    html: &str,
2169    parent_evidence_ids: Vec<EvidenceId>,
2170    asset_fingerprints_selected: bool,
2171) -> PendingWordPressPageObservation {
2172    let mut observation = PendingWordPressPageObservation {
2173        page: page.clone(),
2174        signals: Vec::new(),
2175        discovery: WordPressDiscoverySeedSelection::default(),
2176        association: WordPressPageAssociation::NotEstablished,
2177        outcome: WordPressPageOutcome::NoComponentSignals,
2178        interpreted_response_bytes: u64::try_from(html.len()).unwrap_or(u64::MAX),
2179        parent_evidence_ids,
2180        asset_fingerprint_candidates: Vec::new(),
2181        asset_fingerprint_candidate_limit_exceeded: false,
2182    };
2183    if html.len() > MAX_WORDPRESS_PAGE_RESPONSE_BYTES
2184        || observation.interpreted_response_bytes > MAX_WORDPRESS_PAGE_INTERPRETED_BYTES
2185    {
2186        observation.outcome = WordPressPageOutcome::Truncated;
2187        observation.interpreted_response_bytes = 0;
2188        return observation;
2189    }
2190    let Some(application_url) = frozen.application_url.as_ref() else {
2191        observation.association = WordPressPageAssociation::Rejected;
2192        observation.outcome = WordPressPageOutcome::IncompatibleApplication;
2193        return observation;
2194    };
2195    if page.url.origin() != application_url.origin()
2196        || !directory_is_within_application(application_url, &page.url)
2197    {
2198        observation.association = WordPressPageAssociation::Rejected;
2199        observation.outcome = WordPressPageOutcome::IncompatibleApplication;
2200        return observation;
2201    }
2202    let dom = parse_document(RcDom::default(), ParseOpts::default()).one(html);
2203    let resolution_base = effective_document_base(&dom.document, &page.url);
2204    let mut pending = vec![dom.document.clone()];
2205    let mut conflicting_application_signal = false;
2206    let mut soft_404 = false;
2207    let mut login_response = false;
2208    let mut assets = BTreeMap::<String, Url>::new();
2209    let mut fingerprint_references = BTreeMap::<String, ObservedAssetReference>::new();
2210    let mut fingerprint_reference_limit_exceeded = false;
2211    while let Some(handle) = pending.pop() {
2212        if let NodeData::Element { name, attrs, .. } = &handle.data {
2213            if name.ns == ns!(html) {
2214                let local = name.local.as_ref();
2215                if let Some(classes) = html_attribute(attrs, "class") {
2216                    soft_404 |= classes
2217                        .split_ascii_whitespace()
2218                        .any(|class| matches!(class, "error404" | "error-404" | "not-found"));
2219                }
2220                if let Some(id) = html_attribute(attrs, "id") {
2221                    login_response |= matches!(id.as_str(), "login" | "loginform");
2222                }
2223                if local == "form"
2224                    && html_attribute(attrs, "action")
2225                        .is_some_and(|action| action.contains("wp-login.php"))
2226                {
2227                    login_response = true;
2228                }
2229                if local == "link" && link_has_relation(attrs, "https://api.w.org/") {
2230                    if let (Some(reference), Some(base)) =
2231                        (html_attribute(attrs, "href"), resolution_base.as_ref())
2232                    {
2233                        if let Some(candidate) =
2234                            projected_rest_candidate(&page.url, base, &reference)
2235                        {
2236                            conflicting_application_signal |= rest_candidate_base(&candidate)
2237                                .is_none_or(|base| base != *application_url);
2238                        }
2239                    }
2240                }
2241                let fingerprint_reference = asset_fingerprints_selected
2242                    .then(|| match local {
2243                        "script" => html_attribute(attrs, "src"),
2244                        "link" if link_has_stylesheet_relation(attrs) => {
2245                            html_attribute(attrs, "href")
2246                        },
2247                        _ => None,
2248                    })
2249                    .flatten();
2250                if let (Some(reference), Some(base)) =
2251                    (fingerprint_reference, resolution_base.as_ref())
2252                {
2253                    if crate::http_evidence::wordpress_reference_path_is_safe(&reference) {
2254                        if let Ok(url) = base.join(&reference) {
2255                            if safe_observed_asset_url(&page.url, &url)
2256                                && asset_is_component_candidate(&url, frozen)
2257                            {
2258                                let identity = url.as_str().to_owned();
2259                                if let Some(existing) = fingerprint_references.get_mut(&identity) {
2260                                    if reference < existing.raw_reference {
2261                                        existing.raw_reference.clone_from(&reference);
2262                                        existing.resolution_base.clone_from(base);
2263                                    }
2264                                } else if fingerprint_references.len()
2265                                    == MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES
2266                                {
2267                                    fingerprint_reference_limit_exceeded = true;
2268                                } else {
2269                                    fingerprint_references.insert(
2270                                        identity,
2271                                        ObservedAssetReference {
2272                                            raw_reference: reference,
2273                                            resolution_base: base.clone(),
2274                                            resolved_url: url,
2275                                        },
2276                                    );
2277                                }
2278                            }
2279                        }
2280                    }
2281                }
2282                let asset = match local {
2283                    "link" if link_relation_can_load_resource(attrs) => {
2284                        html_attribute(attrs, "href")
2285                    },
2286                    "script" | "img" | "source" => html_attribute(attrs, "src"),
2287                    _ => None,
2288                };
2289                if let (Some(reference), Some(base)) = (asset, resolution_base.as_ref()) {
2290                    if crate::http_evidence::wordpress_reference_path_is_safe(&reference) {
2291                        if let Ok(url) = base.join(&reference) {
2292                            if safe_observed_asset_url(&page.url, &url)
2293                                && asset_is_component_candidate(&url, frozen)
2294                            {
2295                                assets.insert(url.as_str().to_owned(), url);
2296                            }
2297                        }
2298                    }
2299                }
2300            }
2301        }
2302        pending.extend(handle.children.borrow().iter().rev().cloned());
2303    }
2304    if login_response {
2305        observation.association = WordPressPageAssociation::Rejected;
2306        observation.outcome = WordPressPageOutcome::LoginResponse;
2307        observation.interpreted_response_bytes = 0;
2308        return observation;
2309    }
2310    if soft_404 {
2311        observation.association = WordPressPageAssociation::Rejected;
2312        observation.outcome = WordPressPageOutcome::Soft404;
2313        observation.interpreted_response_bytes = 0;
2314        return observation;
2315    }
2316    for asset in assets.into_values() {
2317        let mut matched_bindings = Vec::new();
2318        for (kind, binding) in [
2319            (WordPressComponentKind::Theme, frozen.themes.as_ref()),
2320            (WordPressComponentKind::Plugin, frozen.plugins.as_ref()),
2321        ] {
2322            let Some(binding) = binding else {
2323                continue;
2324            };
2325            if let Some(component) = component_under_role_base(&asset, &binding.base_url, kind) {
2326                matched_bindings.push((component.clone(), binding.base_url.clone()));
2327                if let Ok(signal) =
2328                    WordPressComponentSignal::same_origin_asset(kind, component.slug())
2329                {
2330                    push_bounded_signal(&mut observation.signals, signal);
2331                }
2332                let filename = match kind {
2333                    WordPressComponentKind::Theme => "style.css",
2334                    WordPressComponentKind::Plugin => "readme.txt",
2335                    WordPressComponentKind::Core => continue,
2336                };
2337                if let Some(resource) =
2338                    exact_role_resource(&binding.base_url, component.slug(), filename)
2339                {
2340                    observation.discovery.insert(
2341                        WordPressDiscoverySeed::admitted_component(
2342                            resource,
2343                            application_url.clone(),
2344                            binding.base_url.clone(),
2345                            binding.association,
2346                            component,
2347                            0,
2348                        )
2349                        .with_source_page_reference(page.page_reference.clone()),
2350                    );
2351                }
2352            }
2353        }
2354        match conventional_component_asset(&asset) {
2355            ConventionalComponentResult::MultipleContentMarkers => {
2356                conflicting_application_signal = true;
2357            },
2358            ConventionalComponentResult::Observed(observation) => {
2359                if !matched_bindings.contains(&(observation.component, observation.role_base)) {
2360                    conflicting_application_signal = true;
2361                }
2362            },
2363            ConventionalComponentResult::NotComponent => {},
2364        }
2365    }
2366    if conflicting_application_signal {
2367        observation.signals.clear();
2368        observation.discovery = WordPressDiscoverySeedSelection::default();
2369        observation.association = WordPressPageAssociation::Rejected;
2370        observation.outcome = WordPressPageOutcome::IncompatibleApplication;
2371    } else if observation.signals.is_empty() {
2372        observation.association = WordPressPageAssociation::NotEstablished;
2373        observation.outcome = WordPressPageOutcome::NoComponentSignals;
2374    } else {
2375        observation.association = WordPressPageAssociation::Accepted;
2376        observation.outcome = WordPressPageOutcome::Accepted;
2377        if asset_fingerprints_selected {
2378            observation.asset_fingerprint_candidates = observed_asset_fingerprint_candidates(
2379                application_url,
2380                &page.url,
2381                frozen,
2382                page.page_reference,
2383                fingerprint_references.into_values(),
2384            );
2385            observation.asset_fingerprint_candidate_limit_exceeded =
2386                fingerprint_reference_limit_exceeded;
2387        }
2388    }
2389    observation
2390}
2391
2392fn asset_is_component_candidate(url: &Url, frozen: &FrozenWordPressPageLayout) -> bool {
2393    !matches!(
2394        conventional_component_asset(url),
2395        ConventionalComponentResult::NotComponent
2396    ) || [
2397        (WordPressComponentKind::Theme, frozen.themes.as_ref()),
2398        (WordPressComponentKind::Plugin, frozen.plugins.as_ref()),
2399    ]
2400    .into_iter()
2401    .any(|(kind, binding)| {
2402        binding.is_some_and(|binding| {
2403            component_under_role_base(url, &binding.base_url, kind).is_some()
2404        })
2405    })
2406}
2407
2408/// Compatibility seam for the bounded signal fuzz/unit corpus. Production
2409/// entry handling calls `extract_wordpress_entry_observation` once instead.
2410#[cfg(any(test, fuzzing))]
2411pub(super) fn extract_wordpress_signals(
2412    document_url: &Url,
2413    html: &str,
2414) -> Vec<WordPressComponentSignal> {
2415    extract_wordpress_entry_observation(
2416        &WordPressDiscoveryContext::new(document_url.clone(), None),
2417        document_url,
2418        html,
2419        &WordPressRestIndexAdvertisement::Missing,
2420        false,
2421        false,
2422    )
2423    .signals
2424}
2425
2426/// Compatibility seam for existing discovery candidate tests and fuzzing.
2427#[cfg(any(test, fuzzing))]
2428pub(super) fn extract_wordpress_discovery_seeds(
2429    document_url: &Url,
2430    html: &str,
2431    rest_header: &WordPressRestIndexAdvertisement,
2432) -> WordPressDiscoverySeedSelection {
2433    extract_wordpress_entry_observation(
2434        &WordPressDiscoveryContext::new(document_url.clone(), None),
2435        document_url,
2436        html,
2437        rest_header,
2438        true,
2439        false,
2440    )
2441    .discovery
2442}
2443
2444#[derive(Clone)]
2445struct ObservedComponentAsset {
2446    resource: Url,
2447    content_base: Option<Url>,
2448    role_base: Url,
2449    component: crate::wordpress_review::WordPressComponentIdentity,
2450    association: WordPressDiscoveryAssociation,
2451}
2452
2453#[derive(Clone)]
2454struct ObservedCoreAsset {
2455    core_base: Url,
2456}
2457
2458struct EntryLayoutResolution<'a> {
2459    context: &'a WordPressDiscoveryContext,
2460    assets: &'a [Url],
2461    rest_candidates: &'a [Url],
2462    rest_invalid: bool,
2463    skipped_foreign_origin_count: u16,
2464    discovery_enabled: bool,
2465}
2466
2467fn resolve_entry_layout(
2468    input: EntryLayoutResolution<'_>,
2469    signals: &mut Vec<WordPressComponentSignal>,
2470    selection: &mut WordPressDiscoverySeedSelection,
2471) {
2472    let EntryLayoutResolution {
2473        context,
2474        assets,
2475        rest_candidates,
2476        rest_invalid,
2477        skipped_foreign_origin_count,
2478        discovery_enabled,
2479    } = input;
2480    let mut layout = context.unresolved_audit();
2481    layout.skipped_foreign_origin_count = skipped_foreign_origin_count;
2482    let declared = context.declared_layout();
2483    let explicit_themes = declared.and_then(WordPressDiscoveryLayout::themes_base_url);
2484    let explicit_plugins = declared.and_then(WordPressDiscoveryLayout::plugins_base_url);
2485    let mut conventional = Vec::new();
2486    let mut core_assets = Vec::new();
2487    let mut conflicting = 0_u16;
2488
2489    for asset in assets {
2490        let conventional_component = match conventional_component_asset(asset) {
2491            ConventionalComponentResult::Observed(observation) => {
2492                conventional.push(*observation);
2493                true
2494            },
2495            ConventionalComponentResult::MultipleContentMarkers => {
2496                conflicting = conflicting.saturating_add(1);
2497                true
2498            },
2499            ConventionalComponentResult::NotComponent => false,
2500        };
2501        let declared_component = [
2502            (WordPressComponentKind::Theme, explicit_themes),
2503            (WordPressComponentKind::Plugin, explicit_plugins),
2504        ]
2505        .into_iter()
2506        .any(|(kind, base)| {
2507            base.is_some_and(|base| component_under_role_base(asset, base, kind).is_some())
2508        });
2509        if !conventional_component && !declared_component {
2510            if let Some(core) = conventional_core_asset(asset) {
2511                core_assets.push(core);
2512            }
2513        }
2514    }
2515
2516    let mut selected_components = Vec::new();
2517    let mut automatic_by_prefix: BTreeMap<String, Vec<ObservedComponentAsset>> = BTreeMap::new();
2518
2519    for asset in assets {
2520        for (kind, base) in [
2521            (WordPressComponentKind::Theme, explicit_themes),
2522            (WordPressComponentKind::Plugin, explicit_plugins),
2523        ] {
2524            if let Some(base) = base {
2525                if let Some(component) = component_under_role_base(asset, base, kind) {
2526                    selected_components.push(ObservedComponentAsset {
2527                        resource: asset.clone(),
2528                        content_base: None,
2529                        role_base: base.clone(),
2530                        component,
2531                        association: WordPressDiscoveryAssociation::ExplicitOperator,
2532                    });
2533                }
2534            }
2535        }
2536    }
2537
2538    for observation in conventional {
2539        if let Some(selected) = selected_components
2540            .iter()
2541            .find(|selected| selected.resource == observation.resource)
2542        {
2543            if selected.component != observation.component {
2544                conflicting = conflicting.saturating_add(1);
2545            }
2546            continue;
2547        }
2548        let role_is_declared = match observation.component.kind() {
2549            WordPressComponentKind::Theme => explicit_themes.is_some(),
2550            WordPressComponentKind::Plugin => explicit_plugins.is_some(),
2551            WordPressComponentKind::Core => true,
2552        };
2553        if role_is_declared {
2554            if !selected_components.iter().any(|selected| {
2555                selected.resource == observation.resource
2556                    && selected.component == observation.component
2557            }) {
2558                conflicting = conflicting.saturating_add(1);
2559            }
2560            continue;
2561        }
2562        let Some(content_base) = observation.content_base.as_ref() else {
2563            continue;
2564        };
2565        if !directory_is_within_application(context.application_url(), content_base) {
2566            layout.skipped_sibling_application_count =
2567                layout.skipped_sibling_application_count.saturating_add(1);
2568            continue;
2569        }
2570        automatic_by_prefix
2571            .entry(content_base.as_str().to_owned())
2572            .or_default()
2573            .push(observation);
2574    }
2575
2576    match automatic_by_prefix.len() {
2577        0 => {},
2578        1 => {
2579            if let Some((_, observations)) = automatic_by_prefix.into_iter().next() {
2580                selected_components.extend(observations);
2581            }
2582        },
2583        count => {
2584            conflicting = conflicting.saturating_add(saturating_u16(count));
2585            for role in [
2586                WordPressDiscoveryLayoutRole::Themes,
2587                WordPressDiscoveryLayoutRole::Plugins,
2588            ] {
2589                if declared_role_base(declared, role).is_none() {
2590                    set_layout_role(
2591                        &mut layout,
2592                        role,
2593                        WordPressDiscoveryLayoutStatus::Ambiguous,
2594                        WordPressDiscoveryLayoutBasis::ConventionalAsset,
2595                        None,
2596                        saturating_u16(count),
2597                    );
2598                }
2599            }
2600        },
2601    }
2602
2603    selected_components.sort_by(|left, right| {
2604        left.component
2605            .cmp(&right.component)
2606            .then_with(|| left.role_base.as_str().cmp(right.role_base.as_str()))
2607            .then_with(|| left.resource.as_str().cmp(right.resource.as_str()))
2608    });
2609    selected_components.dedup_by(|left, right| {
2610        left.component == right.component
2611            && left.role_base == right.role_base
2612            && left.resource == right.resource
2613    });
2614
2615    for role in [
2616        WordPressDiscoveryLayoutRole::Themes,
2617        WordPressDiscoveryLayoutRole::Plugins,
2618    ] {
2619        let role_kind = match role {
2620            WordPressDiscoveryLayoutRole::Themes => WordPressComponentKind::Theme,
2621            WordPressDiscoveryLayoutRole::Plugins => WordPressComponentKind::Plugin,
2622            _ => continue,
2623        };
2624        let matching = selected_components
2625            .iter()
2626            .filter(|entry| entry.component.kind() == role_kind)
2627            .collect::<Vec<_>>();
2628        let bases = matching
2629            .iter()
2630            .map(|entry| entry.role_base.as_str())
2631            .collect::<BTreeSet<_>>();
2632        if let Some(explicit) = declared_role_base(declared, role) {
2633            set_layout_role(
2634                &mut layout,
2635                role,
2636                WordPressDiscoveryLayoutStatus::Exact,
2637                WordPressDiscoveryLayoutBasis::OperatorDeclaration,
2638                Some(explicit),
2639                1,
2640            );
2641        } else if bases.len() == 1 {
2642            set_layout_role(
2643                &mut layout,
2644                role,
2645                WordPressDiscoveryLayoutStatus::Exact,
2646                WordPressDiscoveryLayoutBasis::ConventionalAsset,
2647                matching.first().map(|entry| &entry.role_base),
2648                1,
2649            );
2650        }
2651    }
2652
2653    for observation in selected_components {
2654        let signal = WordPressComponentSignal::same_origin_asset(
2655            observation.component.kind(),
2656            observation.component.slug(),
2657        );
2658        if let Ok(signal) = signal {
2659            push_bounded_signal(signals, signal);
2660        }
2661        if discovery_enabled {
2662            let filename = match observation.component.kind() {
2663                WordPressComponentKind::Theme => "style.css",
2664                WordPressComponentKind::Plugin => "readme.txt",
2665                WordPressComponentKind::Core => continue,
2666            };
2667            if let Some(resource) = exact_role_resource(
2668                &observation.role_base,
2669                observation.component.slug(),
2670                filename,
2671            ) {
2672                selection.insert(WordPressDiscoverySeed::admitted_component(
2673                    resource,
2674                    context.application_url().clone(),
2675                    observation.role_base,
2676                    observation.association,
2677                    observation.component,
2678                    0,
2679                ));
2680            }
2681        }
2682    }
2683
2684    resolve_core_layout(
2685        context,
2686        declared.and_then(WordPressDiscoveryLayout::core_base_url),
2687        &core_assets,
2688        signals,
2689        &mut layout,
2690        &mut conflicting,
2691    );
2692    resolve_rest_layout(
2693        context,
2694        rest_candidates,
2695        rest_invalid,
2696        discovery_enabled,
2697        selection,
2698        &mut layout,
2699        &mut conflicting,
2700    );
2701    layout.conflicting_association_count = conflicting;
2702    selection.layout = Some(layout);
2703}
2704
2705fn declared_role_base(
2706    layout: Option<&WordPressDiscoveryLayout>,
2707    role: WordPressDiscoveryLayoutRole,
2708) -> Option<&Url> {
2709    layout.and_then(|layout| match role {
2710        WordPressDiscoveryLayoutRole::Core => layout.core_base_url(),
2711        WordPressDiscoveryLayoutRole::Themes => layout.themes_base_url(),
2712        WordPressDiscoveryLayoutRole::Plugins => layout.plugins_base_url(),
2713        WordPressDiscoveryLayoutRole::RestIndex => None,
2714    })
2715}
2716
2717fn set_layout_role(
2718    layout: &mut WordPressDiscoveryLayoutAudit,
2719    role: WordPressDiscoveryLayoutRole,
2720    status: WordPressDiscoveryLayoutStatus,
2721    basis: WordPressDiscoveryLayoutBasis,
2722    base: Option<&Url>,
2723    candidate_count: u16,
2724) {
2725    let entry = layout.role_mut(role);
2726    entry.status = status;
2727    entry.basis = basis;
2728    entry.reference = base.map(|base| {
2729        super::wordpress_discovery::opaque_url_reference("wordpress-discovery-role", base)
2730    });
2731    entry.candidate_count = candidate_count;
2732}
2733
2734fn resolve_core_layout(
2735    context: &WordPressDiscoveryContext,
2736    explicit: Option<&Url>,
2737    observations: &[ObservedCoreAsset],
2738    signals: &mut Vec<WordPressComponentSignal>,
2739    layout: &mut WordPressDiscoveryLayoutAudit,
2740    conflicting: &mut u16,
2741) {
2742    let mut candidates = observations
2743        .iter()
2744        .filter(|observation| {
2745            explicit.map_or_else(
2746                || {
2747                    directory_is_within_application(
2748                        context.application_url(),
2749                        &observation.core_base,
2750                    )
2751                },
2752                |base| base == &observation.core_base,
2753            )
2754        })
2755        .map(|observation| observation.core_base.clone())
2756        .collect::<Vec<_>>();
2757    candidates.sort_by(|left, right| left.as_str().cmp(right.as_str()));
2758    candidates.dedup();
2759    let skipped = observations
2760        .iter()
2761        .filter(|observation| !candidates.contains(&observation.core_base))
2762        .count();
2763    *conflicting = conflicting.saturating_add(saturating_u16(skipped));
2764    if let Some(base) = explicit {
2765        set_layout_role(
2766            layout,
2767            WordPressDiscoveryLayoutRole::Core,
2768            WordPressDiscoveryLayoutStatus::Exact,
2769            WordPressDiscoveryLayoutBasis::OperatorDeclaration,
2770            Some(base),
2771            1,
2772        );
2773        if !candidates.is_empty() {
2774            push_bounded_signal(signals, WordPressComponentSignal::core_asset());
2775        }
2776    } else if candidates.len() == 1 {
2777        set_layout_role(
2778            layout,
2779            WordPressDiscoveryLayoutRole::Core,
2780            WordPressDiscoveryLayoutStatus::Exact,
2781            WordPressDiscoveryLayoutBasis::ConventionalAsset,
2782            candidates.first(),
2783            1,
2784        );
2785        push_bounded_signal(signals, WordPressComponentSignal::core_asset());
2786    } else if candidates.len() > 1 {
2787        set_layout_role(
2788            layout,
2789            WordPressDiscoveryLayoutRole::Core,
2790            WordPressDiscoveryLayoutStatus::Ambiguous,
2791            WordPressDiscoveryLayoutBasis::ConventionalAsset,
2792            None,
2793            saturating_u16(candidates.len()),
2794        );
2795    }
2796}
2797
2798fn resolve_rest_layout(
2799    context: &WordPressDiscoveryContext,
2800    candidates: &[Url],
2801    invalid: bool,
2802    discovery_enabled: bool,
2803    selection: &mut WordPressDiscoverySeedSelection,
2804    layout: &mut WordPressDiscoveryLayoutAudit,
2805    conflicting: &mut u16,
2806) {
2807    let declared_core = context
2808        .declared_layout()
2809        .and_then(WordPressDiscoveryLayout::core_base_url);
2810    let mut admitted = Vec::new();
2811    for candidate in candidates {
2812        let Some(base) = rest_candidate_base(candidate) else {
2813            *conflicting = conflicting.saturating_add(1);
2814            continue;
2815        };
2816        let association = if &base == context.application_url() {
2817            Some(WordPressDiscoveryAssociation::StructuredAdvertisement)
2818        } else if declared_core == Some(&base) {
2819            Some(WordPressDiscoveryAssociation::OperatorQualifiedAdvertisement)
2820        } else {
2821            layout.skipped_sibling_application_count =
2822                layout.skipped_sibling_application_count.saturating_add(1);
2823            None
2824        };
2825        if let Some(association) = association {
2826            admitted.push((candidate.clone(), base, association));
2827        }
2828    }
2829    admitted.sort_by(|left, right| left.0.as_str().cmp(right.0.as_str()));
2830    admitted.dedup_by(|left, right| left.0 == right.0);
2831    if admitted.len().saturating_add(usize::from(invalid)) > 1 {
2832        set_layout_role(
2833            layout,
2834            WordPressDiscoveryLayoutRole::RestIndex,
2835            WordPressDiscoveryLayoutStatus::Ambiguous,
2836            WordPressDiscoveryLayoutBasis::StructuredAdvertisement,
2837            None,
2838            saturating_u16(admitted.len().saturating_add(usize::from(invalid))),
2839        );
2840        if discovery_enabled {
2841            selection.insert(WordPressDiscoverySeed::invalid_rest_index(
2842                context.application_url().clone(),
2843            ));
2844        }
2845    } else if invalid {
2846        if discovery_enabled {
2847            selection.insert(WordPressDiscoverySeed::invalid_rest_index(
2848                context.application_url().clone(),
2849            ));
2850        }
2851    } else if let Some((resource, base, association)) = admitted.pop() {
2852        let basis = if association == WordPressDiscoveryAssociation::StructuredAdvertisement {
2853            WordPressDiscoveryLayoutBasis::StructuredAdvertisement
2854        } else {
2855            WordPressDiscoveryLayoutBasis::OperatorDeclaration
2856        };
2857        set_layout_role(
2858            layout,
2859            WordPressDiscoveryLayoutRole::RestIndex,
2860            WordPressDiscoveryLayoutStatus::Exact,
2861            basis,
2862            Some(&base),
2863            1,
2864        );
2865        if discovery_enabled {
2866            selection.insert(WordPressDiscoverySeed::admitted_rest_index(
2867                resource,
2868                context.application_url().clone(),
2869                base,
2870                association,
2871            ));
2872        }
2873    }
2874}
2875
2876enum ConventionalComponentResult {
2877    Observed(Box<ObservedComponentAsset>),
2878    MultipleContentMarkers,
2879    NotComponent,
2880}
2881
2882fn conventional_component_asset(url: &Url) -> ConventionalComponentResult {
2883    let segments = url_segments(url);
2884    let markers = segments
2885        .iter()
2886        .enumerate()
2887        .filter(|(_, segment)| **segment == "wp-content")
2888        .map(|(index, _)| index)
2889        .collect::<Vec<_>>();
2890    let [marker] = markers.as_slice() else {
2891        return if markers.len() > 1 {
2892            ConventionalComponentResult::MultipleContentMarkers
2893        } else {
2894            ConventionalComponentResult::NotComponent
2895        };
2896    };
2897    if segments.len() <= marker.saturating_add(3) {
2898        return ConventionalComponentResult::NotComponent;
2899    }
2900    let kind = match segments.get(marker.saturating_add(1)).copied() {
2901        Some("themes") => WordPressComponentKind::Theme,
2902        Some("plugins") => WordPressComponentKind::Plugin,
2903        _ => return ConventionalComponentResult::NotComponent,
2904    };
2905    let Some(slug) = segments.get(marker.saturating_add(2)).copied() else {
2906        return ConventionalComponentResult::NotComponent;
2907    };
2908    if segments
2909        .get(marker.saturating_add(3))
2910        .is_none_or(|segment| segment.is_empty())
2911    {
2912        return ConventionalComponentResult::NotComponent;
2913    }
2914    let Ok(component) = crate::wordpress_review::WordPressComponentIdentity::new(kind, slug) else {
2915        return ConventionalComponentResult::NotComponent;
2916    };
2917    let Some(content_base) = directory_from_segments(url, &segments[..=(*marker)]) else {
2918        return ConventionalComponentResult::NotComponent;
2919    };
2920    let Some(role_base) = directory_from_segments(url, &segments[..=marker.saturating_add(1)])
2921    else {
2922        return ConventionalComponentResult::NotComponent;
2923    };
2924    ConventionalComponentResult::Observed(Box::new(ObservedComponentAsset {
2925        resource: url.clone(),
2926        content_base: Some(content_base),
2927        role_base,
2928        component,
2929        association: WordPressDiscoveryAssociation::ObservedConventional,
2930    }))
2931}
2932
2933fn conventional_core_asset(url: &Url) -> Option<ObservedCoreAsset> {
2934    let segments = url_segments(url);
2935    let markers = segments
2936        .iter()
2937        .enumerate()
2938        .filter(|(_, segment)| matches!(**segment, "wp-includes" | "wp-admin"))
2939        .map(|(index, _)| index)
2940        .collect::<Vec<_>>();
2941    let [marker] = markers.as_slice() else {
2942        return None;
2943    };
2944    if segments
2945        .get(marker.saturating_add(1))
2946        .is_none_or(|segment| segment.is_empty())
2947    {
2948        return None;
2949    }
2950    directory_from_segments(url, &segments[..*marker])
2951        .map(|core_base| ObservedCoreAsset { core_base })
2952}
2953
2954fn component_under_role_base(
2955    resource: &Url,
2956    base: &Url,
2957    kind: WordPressComponentKind,
2958) -> Option<crate::wordpress_review::WordPressComponentIdentity> {
2959    if resource.origin() != base.origin() {
2960        return None;
2961    }
2962    let resource_segments = url_segments(resource);
2963    let base_segments = url_segments(base);
2964    if resource_segments.len() <= base_segments.len().saturating_add(1)
2965        || !base_segments
2966            .iter()
2967            .zip(&resource_segments)
2968            .all(|(base, resource)| base == resource)
2969    {
2970        return None;
2971    }
2972    let slug = resource_segments[base_segments.len()];
2973    (!resource_segments[base_segments.len().saturating_add(1)].is_empty())
2974        .then(|| crate::wordpress_review::WordPressComponentIdentity::new(kind, slug).ok())
2975        .flatten()
2976}
2977
2978fn observed_asset_fingerprint_candidates(
2979    application_url: &Url,
2980    document_url: &Url,
2981    frozen: &FrozenWordPressPageLayout,
2982    source_page_reference: String,
2983    references: impl IntoIterator<Item = ObservedAssetReference>,
2984) -> Vec<WordPressObservedAssetCandidate> {
2985    let mut candidates = Vec::new();
2986    for reference in references {
2987        for (kind, binding) in [
2988            (WordPressComponentKind::Theme, frozen.themes.as_ref()),
2989            (WordPressComponentKind::Plugin, frozen.plugins.as_ref()),
2990        ] {
2991            let Some(binding) = binding else {
2992                continue;
2993            };
2994            let Some(component) =
2995                component_under_role_base(&reference.resolved_url, &binding.base_url, kind)
2996            else {
2997                continue;
2998            };
2999            let Some(relative_path) = component_relative_asset_path(
3000                &reference.resolved_url,
3001                &binding.base_url,
3002                component.slug(),
3003            ) else {
3004                continue;
3005            };
3006            if let Ok(candidate) = WordPressObservedAssetCandidate::from_html_reference(
3007                application_url,
3008                &binding.base_url,
3009                component,
3010                binding.association,
3011                document_url,
3012                &reference.resolution_base,
3013                &reference.raw_reference,
3014                &reference.resolved_url,
3015                relative_path,
3016                source_page_reference.clone(),
3017            ) {
3018                candidates.push(candidate);
3019            }
3020        }
3021    }
3022    candidates.sort();
3023    candidates.dedup();
3024    candidates
3025}
3026
3027fn component_relative_asset_path(resource: &Url, base: &Url, slug: &str) -> Option<String> {
3028    if resource.origin() != base.origin() {
3029        return None;
3030    }
3031    let resource_segments = url_segments(resource);
3032    let base_segments = url_segments(base);
3033    if resource_segments.get(base_segments.len()).copied() != Some(slug) {
3034        return None;
3035    }
3036    let relative = resource_segments.get(base_segments.len().saturating_add(1)..)?;
3037    (!relative.is_empty()).then(|| relative.join("/"))
3038}
3039
3040fn exact_role_resource(base: &Url, slug: &str, filename: &str) -> Option<Url> {
3041    let resource = base.join(&format!("{slug}/{filename}")).ok()?;
3042    safe_resource_shape(&resource).then_some(resource)
3043}
3044
3045fn projected_rest_candidate(document_url: &Url, base: &Url, reference: &str) -> Option<Url> {
3046    if !crate::http_evidence::wordpress_reference_path_is_safe(reference) {
3047        return None;
3048    }
3049    let candidate = base.join(reference).ok()?;
3050    if !safe_observed_asset_url(document_url, &candidate) {
3051        return None;
3052    }
3053    rest_candidate_base(&candidate).map(|_| candidate)
3054}
3055
3056fn rest_candidate_base(candidate: &Url) -> Option<Url> {
3057    let segments = url_segments(candidate);
3058    if candidate.query().is_none() && segments.last().copied() == Some("wp-json") {
3059        return directory_from_segments(candidate, &segments[..segments.len().saturating_sub(1)]);
3060    }
3061    if !candidate
3062        .query()
3063        .is_some_and(crate::http_evidence::wordpress_rest_route_query_is_admitted)
3064    {
3065        return None;
3066    }
3067    if segments.last().copied() == Some("index.php") {
3068        directory_from_segments(candidate, &segments[..segments.len().saturating_sub(1)])
3069    } else if candidate.path().ends_with('/') {
3070        directory_from_segments(candidate, &segments)
3071    } else {
3072        None
3073    }
3074}
3075
3076fn safe_observed_asset_url(document_url: &Url, candidate: &Url) -> bool {
3077    safe_resource_shape(candidate) && candidate.origin() == document_url.origin()
3078}
3079
3080fn safe_resource_shape(candidate: &Url) -> bool {
3081    matches!(candidate.scheme(), "http" | "https")
3082        && candidate.has_host()
3083        && candidate.username().is_empty()
3084        && candidate.password().is_none()
3085        && candidate.fragment().is_none()
3086        && !candidate.path().contains('\\')
3087        && !candidate
3088            .path()
3089            .split('/')
3090            .enumerate()
3091            .any(|(index, segment)| {
3092                matches!(segment, "." | "..")
3093                    || (segment.is_empty()
3094                        && index != 0
3095                        && index + 1 != candidate.path().split('/').count())
3096                    || ["%2e", "%2f", "%5c", "%25"]
3097                        .iter()
3098                        .any(|encoded| segment.to_ascii_lowercase().contains(encoded))
3099            })
3100}
3101
3102fn asset_is_layout_candidate(context: &WordPressDiscoveryContext, resource: &Url) -> bool {
3103    if !matches!(
3104        conventional_component_asset(resource),
3105        ConventionalComponentResult::NotComponent
3106    ) || conventional_core_asset(resource).is_some()
3107    {
3108        return true;
3109    }
3110    let Some(layout) = context.declared_layout() else {
3111        return false;
3112    };
3113    [
3114        (WordPressComponentKind::Theme, layout.themes_base_url()),
3115        (WordPressComponentKind::Plugin, layout.plugins_base_url()),
3116    ]
3117    .into_iter()
3118    .any(|(kind, base)| {
3119        base.is_some_and(|base| component_under_role_base(resource, base, kind).is_some())
3120    })
3121}
3122
3123fn directory_is_within_application(application: &Url, candidate: &Url) -> bool {
3124    if application.origin() != candidate.origin() {
3125        return false;
3126    }
3127    let application = url_segments(application);
3128    let candidate = url_segments(candidate);
3129    application.len() <= candidate.len()
3130        && application
3131            .iter()
3132            .zip(candidate.iter())
3133            .all(|(application, candidate)| application == candidate)
3134}
3135
3136fn directory_from_segments(source: &Url, segments: &[&str]) -> Option<Url> {
3137    let mut directory = source.clone();
3138    let path = if segments.is_empty() {
3139        "/".to_owned()
3140    } else {
3141        format!("/{}/", segments.join("/"))
3142    };
3143    directory.set_path(&path);
3144    directory.set_query(None);
3145    directory.set_fragment(None);
3146    safe_resource_shape(&directory).then_some(directory)
3147}
3148
3149fn url_segments(url: &Url) -> Vec<&str> {
3150    url.path_segments()
3151        .map(|segments| segments.filter(|segment| !segment.is_empty()).collect())
3152        .unwrap_or_default()
3153}
3154
3155fn saturating_u16(value: usize) -> u16 {
3156    u16::try_from(value).unwrap_or(u16::MAX)
3157}
3158
3159fn insert_bounded_discovery_seed(
3160    seeds: &mut Vec<WordPressDiscoverySeed>,
3161    seed: WordPressDiscoverySeed,
3162    omitted_candidate_count: &mut u64,
3163) -> Result<bool, ()> {
3164    if same_discovery_seed_is_retained(seeds, &seed) {
3165        return Ok(false);
3166    }
3167    let fingerprint = discovery_seed_fingerprint(&seed);
3168    seeds.push(seed);
3169    seeds.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
3170    if seeds.len() > MAX_WORDPRESS_DISCOVERY_CANDIDATES {
3171        retain_request_quota_candidates(seeds);
3172        *omitted_candidate_count = omitted_candidate_count.checked_add(1).ok_or(())?;
3173    }
3174    Ok(seeds
3175        .iter()
3176        .any(|retained| discovery_seed_fingerprint(retained) == fingerprint))
3177}
3178
3179/// Keeps enough deterministic candidates of every kind to exercise each
3180/// closed request quota before filling the remaining retention capacity in
3181/// ordinary stable order. The global 32-candidate bound therefore cannot be
3182/// consumed entirely by one high-cardinality source kind.
3183fn retain_request_quota_candidates(seeds: &mut Vec<WordPressDiscoverySeed>) {
3184    let mut selected = vec![false; seeds.len()];
3185    let mut selected_count = 0_usize;
3186    for (kind, limit) in [
3187        (
3188            WordPressDiscoverySourceKind::RestIndex,
3189            usize::from(MAX_WORDPRESS_DISCOVERY_REST_REQUESTS),
3190        ),
3191        (
3192            WordPressDiscoverySourceKind::ThemeStylesheet,
3193            usize::from(MAX_WORDPRESS_DISCOVERY_THEME_REQUESTS),
3194        ),
3195        (
3196            WordPressDiscoverySourceKind::PluginReadme,
3197            usize::from(MAX_WORDPRESS_DISCOVERY_PLUGIN_REQUESTS),
3198        ),
3199    ] {
3200        for (index, _) in seeds
3201            .iter()
3202            .enumerate()
3203            .filter(|(_, seed)| seed.kind() == kind && seed.preflight_outcome().is_none())
3204            .take(limit)
3205        {
3206            selected[index] = true;
3207            selected_count += 1;
3208        }
3209    }
3210    for selected in &mut selected {
3211        if selected_count == MAX_WORDPRESS_DISCOVERY_CANDIDATES {
3212            break;
3213        }
3214        if !*selected {
3215            *selected = true;
3216            selected_count += 1;
3217        }
3218    }
3219    let mut index = 0_usize;
3220    seeds.retain(|_| {
3221        let retain = selected[index];
3222        index += 1;
3223        retain
3224    });
3225}
3226
3227fn same_discovery_seed_is_retained(
3228    seeds: &[WordPressDiscoverySeed],
3229    seed: &WordPressDiscoverySeed,
3230) -> bool {
3231    seeds
3232        .iter()
3233        .any(|existing| same_discovery_seed(existing, seed))
3234}
3235
3236fn same_discovery_seed(left: &WordPressDiscoverySeed, right: &WordPressDiscoverySeed) -> bool {
3237    discovery_seed_fingerprint(left) == discovery_seed_fingerprint(right)
3238}
3239
3240#[cfg(fuzzing)]
3241pub(super) fn fuzz_check_discovery_candidate_admission(
3242    html: &[u8],
3243    rest_header: &WordPressRestIndexAdvertisement,
3244) {
3245    let origin =
3246        Url::parse("https://wordpress-fuzz.invalid/").expect("fixed fuzz origin must remain valid");
3247    let html = String::from_utf8_lossy(html);
3248    let first = extract_wordpress_discovery_seeds(&origin, &html, rest_header);
3249    let repeated = extract_wordpress_discovery_seeds(&origin, &html, rest_header);
3250    assert_eq!(first.seeds, repeated.seeds);
3251    assert_eq!(
3252        first.seen_candidate_fingerprints,
3253        repeated.seen_candidate_fingerprints
3254    );
3255    assert_eq!(
3256        first.candidate_identity_limit_exceeded,
3257        repeated.candidate_identity_limit_exceeded
3258    );
3259    assert_eq!(
3260        first.omitted_candidate_count,
3261        repeated.omitted_candidate_count
3262    );
3263    assert!(first.seeds.len() <= MAX_WORDPRESS_DISCOVERY_CANDIDATES);
3264    assert!(
3265        first.seen_candidate_fingerprints.len() <= MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
3266    );
3267    assert!(first
3268        .seeds
3269        .windows(2)
3270        .all(|pair| pair[0].sort_key() < pair[1].sort_key()));
3271    assert!(first.seeds.iter().all(|seed| {
3272        seed.url().origin() == origin.origin()
3273            && seed.url().username().is_empty()
3274            && seed.url().password().is_none()
3275            && seed.url().fragment().is_none()
3276            && seed.parent_depth() == 0
3277    }));
3278}
3279
3280fn effective_document_base(
3281    document: &markup5ever_rcdom::Handle,
3282    document_url: &Url,
3283) -> Option<Url> {
3284    let mut pending = vec![document.clone()];
3285    while let Some(handle) = pending.pop() {
3286        if let NodeData::Element { name, attrs, .. } = &handle.data {
3287            if name.ns == ns!(html) && name.local.as_ref() == "base" {
3288                if let Some(reference) = html_attribute(attrs, "href") {
3289                    // An unusable first declared base makes relative asset
3290                    // association ambiguous, so V1 conservatively retains no
3291                    // asset-path signal from this document.
3292                    return crate::http_evidence::wordpress_reference_path_is_safe(&reference)
3293                        .then(|| document_url.join(&reference).ok())
3294                        .flatten();
3295                }
3296            }
3297        }
3298        pending.extend(handle.children.borrow().iter().rev().cloned());
3299    }
3300    Some(document_url.clone())
3301}
3302
3303fn push_bounded_signal(
3304    signals: &mut Vec<WordPressComponentSignal>,
3305    signal: WordPressComponentSignal,
3306) {
3307    if !signals.contains(&signal) && signals.len() <= MAX_WORDPRESS_SIGNALS {
3308        signals.push(signal);
3309    }
3310}
3311
3312fn wordpress_generator_version(content: &str) -> Option<Option<&str>> {
3313    let content = content.trim();
3314    if content == "WordPress" {
3315        return Some(None);
3316    }
3317    let version = content.strip_prefix("WordPress ")?.trim();
3318    (!version.is_empty() && !version.chars().any(char::is_whitespace)).then_some(Some(version))
3319}
3320
3321fn page_url_fingerprint(url: &Url) -> [u8; 32] {
3322    let mut digest = sha2::Sha256::new();
3323    digest.update(b"termivar.wordpress-page-candidate/v1");
3324    digest.update(
3325        u64::try_from(url.as_str().len())
3326            .expect("bounded page URL length fits u64")
3327            .to_be_bytes(),
3328    );
3329    digest.update(url.as_str().as_bytes());
3330    digest.finalize().into()
3331}
3332
3333fn eligible_wordpress_page_candidate(
3334    context: &WordPressDiscoveryContext,
3335    document_url: &Url,
3336    resolution_base: &Url,
3337    reference: &str,
3338) -> Option<Url> {
3339    const MAX_RAW_PAGE_REFERENCE_BYTES: usize = 2_048;
3340    if reference.is_empty()
3341        || reference.len() > MAX_RAW_PAGE_REFERENCE_BYTES
3342        || reference != reference.trim()
3343        || reference.contains(['?', '#', '\\'])
3344        || reference.starts_with("//")
3345        || !crate::http_evidence::wordpress_reference_path_is_safe(reference)
3346    {
3347        return None;
3348    }
3349    let candidate = resolution_base.join(reference).ok()?;
3350    if candidate == *document_url
3351        || candidate.origin() != context.application_url().origin()
3352        || candidate.query().is_some()
3353        || candidate.fragment().is_some()
3354        || !safe_resource_shape(&candidate)
3355        || !directory_is_within_application(context.application_url(), &candidate)
3356    {
3357        return None;
3358    }
3359    let segments = url_segments(&candidate);
3360    if segments.is_empty()
3361        || segments.iter().any(|segment| {
3362            matches!(
3363                segment.to_ascii_lowercase().as_str(),
3364                "wp-admin"
3365                    | "wp-login.php"
3366                    | "wp-json"
3367                    | "xmlrpc.php"
3368                    | "logout"
3369                    | "login"
3370                    | "feed"
3371                    | "download"
3372                    | "downloads"
3373                    | "action"
3374                    | "actions"
3375            )
3376        })
3377    {
3378        return None;
3379    }
3380    let final_segment = segments.last().copied().unwrap_or_default();
3381    let lower = final_segment.to_ascii_lowercase();
3382    if [
3383        ".css", ".js", ".json", ".xml", ".txt", ".pdf", ".zip", ".gz", ".jpg", ".jpeg", ".png",
3384        ".gif", ".svg", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".mp3", ".mp4",
3385        ".webm",
3386    ]
3387    .iter()
3388    .any(|extension| lower.ends_with(extension))
3389    {
3390        return None;
3391    }
3392    Some(candidate)
3393}
3394
3395fn select_wordpress_page_candidates(
3396    candidates: impl IntoIterator<Item = Url>,
3397    distinct_candidate_count: usize,
3398    candidate_identity_limit_exceeded: bool,
3399) -> WordPressPageCandidateSelection {
3400    let mut candidates = candidates.into_iter().collect::<Vec<_>>();
3401    candidates.sort_by(|left, right| left.as_str().cmp(right.as_str()));
3402    candidates.dedup();
3403    candidates.truncate(MAX_WORDPRESS_SELECTED_PAGES);
3404    let candidate_count = u16::try_from(distinct_candidate_count).unwrap_or(u16::MAX);
3405    let selected_count = u16::try_from(candidates.len()).unwrap_or(u16::MAX);
3406    WordPressPageCandidateSelection {
3407        candidates: candidates
3408            .into_iter()
3409            .map(|url| WordPressPageCandidate {
3410                page_reference: super::wordpress_discovery::opaque_url_reference(
3411                    "wordpress-discovery-page",
3412                    &url,
3413                ),
3414                url,
3415            })
3416            .collect(),
3417        candidate_count,
3418        omitted_candidate_count: candidate_count.saturating_sub(selected_count),
3419        candidate_identity_limit_exceeded,
3420    }
3421}
3422
3423fn freeze_page_layout(
3424    context: &WordPressDiscoveryContext,
3425    selection: &mut WordPressDiscoverySeedSelection,
3426) -> FrozenWordPressPageLayout {
3427    let mut frozen = FrozenWordPressPageLayout {
3428        application_url: Some(context.application_url().clone()),
3429        themes: context
3430            .declared_layout()
3431            .and_then(WordPressDiscoveryLayout::themes_base_url)
3432            .cloned()
3433            .map(|base_url| FrozenWordPressRoleBinding {
3434                base_url,
3435                association: WordPressDiscoveryAssociation::ExplicitOperator,
3436            }),
3437        plugins: context
3438            .declared_layout()
3439            .and_then(WordPressDiscoveryLayout::plugins_base_url)
3440            .cloned()
3441            .map(|base_url| FrozenWordPressRoleBinding {
3442                base_url,
3443                association: WordPressDiscoveryAssociation::ExplicitOperator,
3444            }),
3445    };
3446    for seed in &selection.seeds {
3447        match seed.kind() {
3448            WordPressDiscoverySourceKind::ThemeStylesheet if frozen.themes.is_none() => {
3449                frozen.themes = Some(FrozenWordPressRoleBinding {
3450                    base_url: seed.role_base_url().clone(),
3451                    association: seed.association(),
3452                });
3453            },
3454            WordPressDiscoverySourceKind::PluginReadme if frozen.plugins.is_none() => {
3455                frozen.plugins = Some(FrozenWordPressRoleBinding {
3456                    base_url: seed.role_base_url().clone(),
3457                    association: seed.association(),
3458                });
3459            },
3460            _ => {},
3461        }
3462    }
3463    let conventional_content_base = selection
3464        .seeds
3465        .iter()
3466        .filter_map(|seed| {
3467            let role = match seed.kind() {
3468                WordPressDiscoverySourceKind::ThemeStylesheet => "themes",
3469                WordPressDiscoverySourceKind::PluginReadme => "plugins",
3470                WordPressDiscoverySourceKind::RestIndex => return None,
3471            };
3472            let url = seed.role_base_url();
3473            let mut segments = url_segments(url);
3474            let role_matches = segments.pop() == Some(role);
3475            let content_matches = segments.last().copied() == Some("wp-content");
3476            (role_matches
3477                && content_matches
3478                && directory_is_within_application(context.application_url(), url))
3479            .then(|| directory_from_segments(url, &segments))?
3480        })
3481        .collect::<BTreeSet<_>>();
3482    if conventional_content_base.len() == 1 {
3483        let content_base = conventional_content_base.iter().next().expect("one base");
3484        if frozen.themes.is_none() {
3485            frozen.themes =
3486                content_base
3487                    .join("themes/")
3488                    .ok()
3489                    .map(|base_url| FrozenWordPressRoleBinding {
3490                        base_url,
3491                        association: WordPressDiscoveryAssociation::ObservedConventional,
3492                    });
3493            if let (Some(layout), Some(binding)) =
3494                (selection.layout.as_mut(), frozen.themes.as_ref())
3495            {
3496                set_layout_role(
3497                    layout,
3498                    WordPressDiscoveryLayoutRole::Themes,
3499                    WordPressDiscoveryLayoutStatus::Exact,
3500                    WordPressDiscoveryLayoutBasis::ConventionalAsset,
3501                    Some(&binding.base_url),
3502                    1,
3503                );
3504            }
3505        }
3506        if frozen.plugins.is_none() {
3507            frozen.plugins =
3508                content_base
3509                    .join("plugins/")
3510                    .ok()
3511                    .map(|base_url| FrozenWordPressRoleBinding {
3512                        base_url,
3513                        association: WordPressDiscoveryAssociation::ObservedConventional,
3514                    });
3515            if let (Some(layout), Some(binding)) =
3516                (selection.layout.as_mut(), frozen.plugins.as_ref())
3517            {
3518                set_layout_role(
3519                    layout,
3520                    WordPressDiscoveryLayoutRole::Plugins,
3521                    WordPressDiscoveryLayoutStatus::Exact,
3522                    WordPressDiscoveryLayoutBasis::ConventionalAsset,
3523                    Some(&binding.base_url),
3524                    1,
3525                );
3526            }
3527        }
3528    }
3529    frozen
3530}
3531
3532fn html_has_attribute(
3533    attrs: &std::cell::RefCell<Vec<html5ever::Attribute>>,
3534    local_name: &str,
3535) -> bool {
3536    attrs
3537        .borrow()
3538        .iter()
3539        .any(|attribute| attribute.name.ns == ns!() && attribute.name.local.as_ref() == local_name)
3540}
3541
3542fn html_attribute(
3543    attrs: &std::cell::RefCell<Vec<html5ever::Attribute>>,
3544    local_name: &str,
3545) -> Option<String> {
3546    attrs
3547        .borrow()
3548        .iter()
3549        .find(|attribute| attribute.name.ns == ns!() && attribute.name.local.as_ref() == local_name)
3550        .map(|attribute| attribute.value.to_string())
3551}
3552
3553fn link_relation_can_load_resource(attrs: &std::cell::RefCell<Vec<html5ever::Attribute>>) -> bool {
3554    html_attribute(attrs, "rel").is_some_and(|relations| {
3555        relations.split_ascii_whitespace().any(|relation| {
3556            ["stylesheet", "icon", "preload", "modulepreload"]
3557                .iter()
3558                .any(|supported| relation.eq_ignore_ascii_case(supported))
3559        })
3560    })
3561}
3562
3563fn link_has_stylesheet_relation(attrs: &std::cell::RefCell<Vec<html5ever::Attribute>>) -> bool {
3564    html_attribute(attrs, "rel").is_some_and(|relations| {
3565        relations
3566            .split_ascii_whitespace()
3567            .any(|relation| relation.eq_ignore_ascii_case("stylesheet"))
3568    })
3569}
3570
3571fn link_has_relation(
3572    attrs: &std::cell::RefCell<Vec<html5ever::Attribute>>,
3573    expected: &str,
3574) -> bool {
3575    html_attribute(attrs, "rel").is_some_and(|relations| {
3576        relations
3577            .split_ascii_whitespace()
3578            .any(|relation| relation == expected)
3579    })
3580}
3581
3582#[cfg(test)]
3583mod tests {
3584    use super::*;
3585    use crate::wordpress_review::{
3586        parse_wordpress_discovery_layout, WordPressComponentIdentity, WordPressEvidenceSource,
3587    };
3588
3589    fn origin() -> Url {
3590        Url::parse("https://example.test/").unwrap()
3591    }
3592
3593    fn seed_urls(selection: &WordPressDiscoverySeedSelection) -> Vec<&str> {
3594        selection
3595            .seeds()
3596            .iter()
3597            .map(|seed| seed.url().as_str())
3598            .collect()
3599    }
3600
3601    #[test]
3602    fn selected_blog_application_preserves_component_and_rest_bases() {
3603        let application = Url::parse("https://example.test/blog/").unwrap();
3604        let observation = extract_wordpress_entry_observation(
3605            &WordPressDiscoveryContext::new(application.clone(), None),
3606            &application,
3607            r#"<meta name="generator" content="WordPress 6.9.4">
3608                <link rel="https://api.w.org/" href="/blog/wp-json/">
3609                <link rel="stylesheet" href="/blog/wp-content/themes/child/assets/site.css">
3610                <script src="/blog/wp-content/plugins/cache-tool/assets/app.js"></script>
3611                <img src="/blog/wp-includes/images/blank.gif">
3612                <script src="/shop/wp-content/plugins/sibling/assets/app.js"></script>"#,
3613            &WordPressRestIndexAdvertisement::Missing,
3614            true,
3615            false,
3616        );
3617
3618        assert_eq!(
3619            seed_urls(&observation.discovery),
3620            vec![
3621                "https://example.test/blog/wp-json/",
3622                "https://example.test/blog/wp-content/themes/child/style.css",
3623                "https://example.test/blog/wp-content/plugins/cache-tool/readme.txt",
3624            ]
3625        );
3626        let identities = observation
3627            .signals
3628            .iter()
3629            .map(|signal| (signal.identity().kind(), signal.identity().slug()))
3630            .collect::<BTreeSet<_>>();
3631        assert!(identities.contains(&(WordPressComponentKind::Core, "wordpress")));
3632        assert!(identities.contains(&(WordPressComponentKind::Theme, "child")));
3633        assert!(identities.contains(&(WordPressComponentKind::Plugin, "cache-tool")));
3634        assert!(!identities.contains(&(WordPressComponentKind::Plugin, "sibling")));
3635        let layout = observation.discovery.layout.as_ref().unwrap();
3636        assert_eq!(layout.skipped_sibling_application_count(), 1);
3637    }
3638
3639    #[test]
3640    fn root_application_preserves_one_observed_cms_content_prefix() {
3641        let application = origin();
3642        let observation = extract_wordpress_entry_observation(
3643            &WordPressDiscoveryContext::new(application.clone(), None),
3644            &application,
3645            r#"<link rel="https://api.w.org/" href="/wp-json/">
3646                <link rel="stylesheet" href="/cms/wp-content/themes/child/assets/site.css">
3647                <script src="/cms/wp-content/plugins/cache-tool/assets/app.js"></script>
3648                <img src="/cms/wp-includes/images/blank.gif">"#,
3649            &WordPressRestIndexAdvertisement::Missing,
3650            true,
3651            false,
3652        );
3653
3654        assert_eq!(
3655            seed_urls(&observation.discovery),
3656            vec![
3657                "https://example.test/wp-json/",
3658                "https://example.test/cms/wp-content/themes/child/style.css",
3659                "https://example.test/cms/wp-content/plugins/cache-tool/readme.txt",
3660            ]
3661        );
3662        let layout = observation.discovery.layout.as_ref().unwrap();
3663        let core = layout
3664            .roles()
3665            .iter()
3666            .find(|role| role.role() == WordPressDiscoveryLayoutRole::Core)
3667            .unwrap();
3668        assert_eq!(core.status(), WordPressDiscoveryLayoutStatus::Exact);
3669        assert_eq!(
3670            core.basis(),
3671            WordPressDiscoveryLayoutBasis::ConventionalAsset
3672        );
3673    }
3674
3675    #[test]
3676    fn component_internal_core_named_paths_do_not_create_a_core_association() {
3677        let application = origin();
3678        let observation = extract_wordpress_entry_observation(
3679            &WordPressDiscoveryContext::new(application.clone(), None),
3680            &application,
3681            r#"<script src="/wp-content/plugins/cache-tool/wp-includes/app.js"></script>
3682                <link rel="stylesheet" href="/wp-content/themes/child/wp-admin/editor.css">"#,
3683            &WordPressRestIndexAdvertisement::Missing,
3684            true,
3685            false,
3686        );
3687
3688        assert_eq!(
3689            seed_urls(&observation.discovery),
3690            vec![
3691                "https://example.test/wp-content/themes/child/style.css",
3692                "https://example.test/wp-content/plugins/cache-tool/readme.txt",
3693            ]
3694        );
3695        assert!(!observation
3696            .signals
3697            .iter()
3698            .any(|signal| signal.identity().kind() == WordPressComponentKind::Core));
3699        let core = observation
3700            .discovery
3701            .layout
3702            .as_ref()
3703            .unwrap()
3704            .roles()
3705            .iter()
3706            .find(|role| role.role() == WordPressDiscoveryLayoutRole::Core)
3707            .unwrap();
3708        assert_eq!(core.status(), WordPressDiscoveryLayoutStatus::Unresolved);
3709    }
3710
3711    #[test]
3712    fn operator_layout_qualifies_only_observed_custom_role_assets() {
3713        let bytes = br#"{
3714          "schema":"security.wordpress-layout/v1",
3715          "application_url":"https://example.test/blog/",
3716          "core_base_url":"https://example.test/cms/",
3717          "themes_base_url":"https://example.test/site-content/themes/",
3718          "plugins_base_url":"https://example.test/modules/"
3719        }"#;
3720        let declared = parse_wordpress_discovery_layout(bytes).unwrap();
3721        let application = Url::parse("https://example.test/blog/").unwrap();
3722        let observation = extract_wordpress_entry_observation(
3723            &WordPressDiscoveryContext::new(application.clone(), Some(declared)),
3724            &application,
3725            r#"<link rel="https://api.w.org/" href="/cms/wp-json/">
3726                <link rel="stylesheet" href="/site-content/themes/child/assets/site.css">
3727                <script src="/modules/cache-tool/assets/app.js"></script>
3728                <script src="/shop/wp-content/plugins/cache-tool/assets/decoy.js"></script>"#,
3729            &WordPressRestIndexAdvertisement::Missing,
3730            true,
3731            false,
3732        );
3733
3734        assert_eq!(
3735            seed_urls(&observation.discovery),
3736            vec![
3737                "https://example.test/cms/wp-json/",
3738                "https://example.test/site-content/themes/child/style.css",
3739                "https://example.test/modules/cache-tool/readme.txt",
3740            ]
3741        );
3742        assert!(observation
3743            .discovery
3744            .seeds()
3745            .iter()
3746            .all(|seed| seed.association() != WordPressDiscoveryAssociation::ObservedConventional));
3747        assert!(!observation
3748            .signals
3749            .iter()
3750            .any(|signal| signal.identity().slug() == "cache-tool"
3751                && signal.identity().kind() == WordPressComponentKind::Plugin
3752                && observation.discovery.seeds().iter().any(|seed| {
3753                    seed.url().path().starts_with("/shop/")
3754                        && seed.component_identity() == Some(signal.identity())
3755                })));
3756    }
3757
3758    #[test]
3759    fn explicit_role_binding_excludes_cross_role_conventional_inference() {
3760        let application = origin();
3761        for (layout, html, expected_url, expected_kind) in [
3762            (
3763                br#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","themes_base_url":"https://example.test/wp-content/plugins/"}"#.as_slice(),
3764                r#"<script src="/wp-content/plugins/cross-role/app.js"></script>"#,
3765                "https://example.test/wp-content/plugins/cross-role/style.css",
3766                WordPressComponentKind::Theme,
3767            ),
3768            (
3769                br#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","plugins_base_url":"https://example.test/wp-content/themes/"}"#.as_slice(),
3770                r#"<link rel="stylesheet" href="/wp-content/themes/cross-role/site.css">"#,
3771                "https://example.test/wp-content/themes/cross-role/readme.txt",
3772                WordPressComponentKind::Plugin,
3773            ),
3774        ] {
3775            let declared = parse_wordpress_discovery_layout(layout).unwrap();
3776            let observation = extract_wordpress_entry_observation(
3777                &WordPressDiscoveryContext::new(application.clone(), Some(declared)),
3778                &application,
3779                html,
3780                &WordPressRestIndexAdvertisement::Missing,
3781                true,
3782                false,
3783            );
3784
3785            assert_eq!(seed_urls(&observation.discovery), vec![expected_url]);
3786            let seed = &observation.discovery.seeds()[0];
3787            assert_eq!(seed.component_identity().unwrap().kind(), expected_kind);
3788            assert_eq!(
3789                seed.association(),
3790                WordPressDiscoveryAssociation::ExplicitOperator
3791            );
3792            assert_eq!(
3793                observation
3794                    .discovery
3795                    .layout
3796                    .as_ref()
3797                    .unwrap()
3798                    .conflicting_association_count(),
3799                1
3800            );
3801        }
3802    }
3803
3804    #[test]
3805    fn unrelated_assets_do_not_exhaust_wordpress_candidate_identity_budget() {
3806        let ordinary = (0..=MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES)
3807            .map(|index| format!(r#"<img src="/ordinary/image-{index}.png">"#))
3808            .collect::<String>();
3809        let observation = extract_wordpress_entry_observation(
3810            &WordPressDiscoveryContext::new(origin(), None),
3811            &origin(),
3812            &format!(
3813                r#"{ordinary}<script src="/wp-content/plugins/retained/assets/app.js"></script>"#
3814            ),
3815            &WordPressRestIndexAdvertisement::Missing,
3816            true,
3817            false,
3818        );
3819
3820        assert!(!observation.discovery.candidate_identity_limit_exceeded);
3821        assert_eq!(
3822            seed_urls(&observation.discovery),
3823            vec!["https://example.test/wp-content/plugins/retained/readme.txt"]
3824        );
3825    }
3826
3827    #[test]
3828    fn page_candidates_are_anchor_only_query_free_and_application_scoped() {
3829        let application = Url::parse("https://example.test/blog/").unwrap();
3830        let observation = extract_wordpress_entry_observation(
3831            &WordPressDiscoveryContext::new(application.clone(), None),
3832            &application,
3833            r#"<a href="/blog/contact">contact</a>
3834                <a href="/blog/gallery/">gallery</a>
3835                <a href="/blog/contact#team">fragment</a>
3836                <a href="/blog/?page_id=123">query</a>
3837                <a href="/blog/download/file">download route</a>
3838                <a href="/blog/wp-admin/edit.php">admin</a>
3839                <a href="/blogger/contact">sibling prefix</a>
3840                <a href="https://other.test/blog/contact">foreign</a>
3841                <a href="/blog/ignored" download>download attribute</a>
3842                <form action="/blog/form-only"></form>
3843                <script src="/blog/asset-only"></script>"#,
3844            &WordPressRestIndexAdvertisement::Missing,
3845            true,
3846            true,
3847        );
3848
3849        assert_eq!(observation.page_candidates.candidate_count, 2);
3850        assert_eq!(observation.page_candidates.omitted_candidate_count, 0);
3851        assert_eq!(
3852            observation
3853                .page_candidates
3854                .candidates
3855                .iter()
3856                .map(|candidate| candidate.url.as_str())
3857                .collect::<Vec<_>>(),
3858            [
3859                "https://example.test/blog/contact",
3860                "https://example.test/blog/gallery/",
3861            ]
3862        );
3863    }
3864
3865    #[test]
3866    fn page_candidate_top_k_is_stable_and_identity_overflow_fails_closed() {
3867        let ordered = (0..40)
3868            .rev()
3869            .map(|index| format!(r#"<a href="/page-{index:04}">page</a>"#))
3870            .collect::<String>();
3871        let first = extract_wordpress_entry_observation(
3872            &WordPressDiscoveryContext::new(origin(), None),
3873            &origin(),
3874            &ordered,
3875            &WordPressRestIndexAdvertisement::Missing,
3876            true,
3877            true,
3878        );
3879        let ascending = (0..40)
3880            .map(|index| format!(r#"<a href="/page-{index:04}">page</a>"#))
3881            .collect::<String>();
3882        let second = extract_wordpress_entry_observation(
3883            &WordPressDiscoveryContext::new(origin(), None),
3884            &origin(),
3885            &ascending,
3886            &WordPressRestIndexAdvertisement::Missing,
3887            true,
3888            true,
3889        );
3890        assert_eq!(first.page_candidates.candidate_count, 40);
3891        assert_eq!(first.page_candidates.omitted_candidate_count, 37);
3892        assert_eq!(
3893            first.page_candidates.candidates,
3894            second.page_candidates.candidates
3895        );
3896        assert_eq!(
3897            first
3898                .page_candidates
3899                .candidates
3900                .iter()
3901                .map(|candidate| candidate.url.path())
3902                .collect::<Vec<_>>(),
3903            ["/page-0000", "/page-0001", "/page-0002"]
3904        );
3905
3906        let over_limit = (0..=MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES)
3907            .map(|index| format!(r#"<a href="/candidate-{index}">page</a>"#))
3908            .collect::<String>();
3909        let over_limit = extract_wordpress_entry_observation(
3910            &WordPressDiscoveryContext::new(origin(), None),
3911            &origin(),
3912            &over_limit,
3913            &WordPressRestIndexAdvertisement::Missing,
3914            true,
3915            true,
3916        );
3917        assert!(over_limit.page_candidates.candidate_identity_limit_exceeded);
3918        assert_eq!(
3919            usize::from(over_limit.page_candidates.candidate_count),
3920            MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
3921        );
3922    }
3923
3924    #[test]
3925    fn secondary_page_cannot_replace_the_frozen_application_layout() {
3926        let application = Url::parse("https://example.test/blog/").unwrap();
3927        let entry = extract_wordpress_entry_observation(
3928            &WordPressDiscoveryContext::new(application.clone(), None),
3929            &application,
3930            r#"<a href="/blog/contact">contact</a>
3931                <script src="/blog/wp-content/plugins/entry-plugin/app.js"></script>"#,
3932            &WordPressRestIndexAdvertisement::Missing,
3933            true,
3934            true,
3935        );
3936        let candidate = entry.page_candidates.candidates[0].clone();
3937        let page = extract_wordpress_page_observation_with_asset_fingerprints(
3938            candidate,
3939            &entry.frozen_page_layout,
3940            r#"<meta name="generator" content="WordPress 99.0">
3941                <link rel="https://api.w.org/" href="/shop/wp-json/">
3942                <script src="/shop/wp-content/plugins/sibling/app.js"></script>
3943                <script src="/blog/wp-content/plugins/tempting/app.js"></script>"#,
3944            Vec::new(),
3945            true,
3946        );
3947
3948        assert_eq!(page.association, WordPressPageAssociation::Rejected);
3949        assert_eq!(page.outcome, WordPressPageOutcome::IncompatibleApplication);
3950        assert!(page.signals.is_empty());
3951        assert!(page.discovery.seeds.is_empty());
3952        assert!(page.asset_fingerprint_candidates.is_empty());
3953    }
3954
3955    #[test]
3956    fn secondary_pages_preserve_declared_custom_role_bindings() {
3957        let application = Url::parse("https://example.test/blog/").unwrap();
3958        let declared = parse_wordpress_discovery_layout(
3959            br#"{
3960              "schema":"security.wordpress-layout/v1",
3961              "application_url":"https://example.test/blog/",
3962              "themes_base_url":"https://example.test/site-content/themes/",
3963              "plugins_base_url":"https://example.test/modules/"
3964            }"#,
3965        )
3966        .unwrap();
3967        let entry = extract_wordpress_entry_observation(
3968            &WordPressDiscoveryContext::new(application.clone(), Some(declared)),
3969            &application,
3970            r#"<a href="/blog/contact/">contact</a>"#,
3971            &WordPressRestIndexAdvertisement::Missing,
3972            true,
3973            true,
3974        );
3975        let candidate = entry.page_candidates.candidates[0].clone();
3976        let html = r#"<script src="/modules/conditional/assets/fingerprint.js?ver=cache-42"></script>
3977            <link rel="stylesheet" href="/modules/conditional/assets/fingerprint.css?ver=cache-42">"#;
3978
3979        let without_fingerprints = extract_wordpress_page_observation_with_asset_fingerprints(
3980            candidate.clone(),
3981            &entry.frozen_page_layout,
3982            html,
3983            Vec::new(),
3984            false,
3985        );
3986        assert_eq!(
3987            without_fingerprints.association,
3988            WordPressPageAssociation::Accepted
3989        );
3990        assert_eq!(without_fingerprints.discovery.seeds().len(), 1);
3991        assert_eq!(
3992            without_fingerprints.discovery.seeds()[0].association(),
3993            WordPressDiscoveryAssociation::ExplicitOperator
3994        );
3995        assert_eq!(
3996            without_fingerprints.discovery.seeds()[0].url().as_str(),
3997            "https://example.test/modules/conditional/readme.txt"
3998        );
3999        assert!(without_fingerprints.asset_fingerprint_candidates.is_empty());
4000
4001        let with_fingerprints = extract_wordpress_page_observation_with_asset_fingerprints(
4002            candidate,
4003            &entry.frozen_page_layout,
4004            html,
4005            Vec::new(),
4006            true,
4007        );
4008        assert_eq!(
4009            with_fingerprints.association,
4010            WordPressPageAssociation::Accepted
4011        );
4012        assert_eq!(with_fingerprints.discovery.seeds().len(), 1);
4013        assert_eq!(
4014            with_fingerprints.discovery.seeds()[0].association(),
4015            WordPressDiscoveryAssociation::ExplicitOperator
4016        );
4017        assert_eq!(with_fingerprints.asset_fingerprint_candidates.len(), 2);
4018        assert!(with_fingerprints
4019            .asset_fingerprint_candidates
4020            .iter()
4021            .all(|candidate| candidate.component().slug() == "conditional"));
4022    }
4023
4024    #[test]
4025    fn secondary_custom_roles_require_a_frozen_binding_and_retain_conflicts() {
4026        let application = Url::parse("https://example.test/blog/").unwrap();
4027        let unresolved = extract_wordpress_entry_observation(
4028            &WordPressDiscoveryContext::new(application.clone(), None),
4029            &application,
4030            r#"<a href="/blog/contact/">contact</a>"#,
4031            &WordPressRestIndexAdvertisement::Missing,
4032            true,
4033            true,
4034        );
4035        let unresolved_page = extract_wordpress_page_observation_with_asset_fingerprints(
4036            unresolved.page_candidates.candidates[0].clone(),
4037            &unresolved.frozen_page_layout,
4038            r#"<script src="/modules/conditional/assets/app.js"></script>"#,
4039            Vec::new(),
4040            true,
4041        );
4042        assert_eq!(
4043            unresolved_page.association,
4044            WordPressPageAssociation::NotEstablished
4045        );
4046        assert!(unresolved_page.discovery.seeds().is_empty());
4047        assert!(unresolved_page.asset_fingerprint_candidates.is_empty());
4048
4049        let declared = parse_wordpress_discovery_layout(
4050            br#"{
4051              "schema":"security.wordpress-layout/v1",
4052              "application_url":"https://example.test/blog/",
4053              "plugins_base_url":"https://example.test/modules/"
4054            }"#,
4055        )
4056        .unwrap();
4057        let frozen = extract_wordpress_entry_observation(
4058            &WordPressDiscoveryContext::new(application.clone(), Some(declared)),
4059            &application,
4060            r#"<a href="/blog/contact/">contact</a>"#,
4061            &WordPressRestIndexAdvertisement::Missing,
4062            true,
4063            true,
4064        );
4065        let lookalike = extract_wordpress_page_observation_with_asset_fingerprints(
4066            frozen.page_candidates.candidates[0].clone(),
4067            &frozen.frozen_page_layout,
4068            r#"<script src="/modules-evil/conditional/assets/app.js"></script>"#,
4069            Vec::new(),
4070            true,
4071        );
4072        assert_eq!(
4073            lookalike.association,
4074            WordPressPageAssociation::NotEstablished
4075        );
4076        assert!(lookalike.discovery.seeds().is_empty());
4077
4078        let conflicting = extract_wordpress_page_observation_with_asset_fingerprints(
4079            frozen.page_candidates.candidates[0].clone(),
4080            &frozen.frozen_page_layout,
4081            r#"<script src="/modules/conditional/assets/app.js"></script>
4082                <script src="/blog/wp-content/plugins/sibling/assets/app.js"></script>"#,
4083            Vec::new(),
4084            true,
4085        );
4086        assert_eq!(conflicting.association, WordPressPageAssociation::Rejected);
4087        assert_eq!(
4088            conflicting.outcome,
4089            WordPressPageOutcome::IncompatibleApplication
4090        );
4091        assert!(conflicting.signals.is_empty());
4092        assert!(conflicting.discovery.seeds().is_empty());
4093        assert!(conflicting.asset_fingerprint_candidates.is_empty());
4094
4095        let nested_conflict = extract_wordpress_page_observation_with_asset_fingerprints(
4096            frozen.page_candidates.candidates[0].clone(),
4097            &frozen.frozen_page_layout,
4098            r#"<script src="/modules/conditional/vendor/wp-content/plugins/sibling/app.js"></script>"#,
4099            Vec::new(),
4100            true,
4101        );
4102        assert_eq!(
4103            nested_conflict.association,
4104            WordPressPageAssociation::Rejected
4105        );
4106        assert_eq!(
4107            nested_conflict.outcome,
4108            WordPressPageOutcome::IncompatibleApplication
4109        );
4110        assert!(nested_conflict.signals.is_empty());
4111        assert!(nested_conflict.discovery.seeds().is_empty());
4112        assert!(nested_conflict.asset_fingerprint_candidates.is_empty());
4113
4114        let same_slug_nested_base = extract_wordpress_page_observation_with_asset_fingerprints(
4115            frozen.page_candidates.candidates[0].clone(),
4116            &frozen.frozen_page_layout,
4117            r#"<script src="/modules/conditional/vendor/wp-content/plugins/conditional/app.js"></script>"#,
4118            Vec::new(),
4119            true,
4120        );
4121        assert_eq!(
4122            same_slug_nested_base.association,
4123            WordPressPageAssociation::Rejected
4124        );
4125        assert_eq!(
4126            same_slug_nested_base.outcome,
4127            WordPressPageOutcome::IncompatibleApplication
4128        );
4129        assert!(same_slug_nested_base.signals.is_empty());
4130        assert!(same_slug_nested_base.discovery.seeds().is_empty());
4131        assert!(same_slug_nested_base
4132            .asset_fingerprint_candidates
4133            .is_empty());
4134
4135        let declared_theme = parse_wordpress_discovery_layout(
4136            br#"{
4137              "schema":"security.wordpress-layout/v1",
4138              "application_url":"https://example.test/blog/",
4139              "themes_base_url":"https://example.test/site-content/themes/"
4140            }"#,
4141        )
4142        .unwrap();
4143        let theme_entry = extract_wordpress_entry_observation(
4144            &WordPressDiscoveryContext::new(application.clone(), Some(declared_theme)),
4145            &application,
4146            r#"<a href="/blog/contact/">contact</a>
4147                <link rel="stylesheet" href="/site-content/themes/child/assets/site.css">"#,
4148            &WordPressRestIndexAdvertisement::Missing,
4149            true,
4150            true,
4151        );
4152        let invented_sibling = extract_wordpress_page_observation_with_asset_fingerprints(
4153            theme_entry.page_candidates.candidates[0].clone(),
4154            &theme_entry.frozen_page_layout,
4155            r#"<script src="/site-content/plugins/not-declared/assets/app.js"></script>"#,
4156            Vec::new(),
4157            false,
4158        );
4159        assert_eq!(
4160            invented_sibling.association,
4161            WordPressPageAssociation::NotEstablished
4162        );
4163        assert!(invented_sibling.discovery.seeds().is_empty());
4164    }
4165
4166    #[test]
4167    fn structured_generator_and_same_origin_assets_are_retained() {
4168        let signals = extract_wordpress_signals(
4169            &origin(),
4170            r#"<meta name="generator" content="WordPress 6.9.4">
4171                <link rel="stylesheet" href="/wp-content/themes/twentytwenty/style.css?ver=99">
4172                <script src="/wp-content/plugins/cache-tool/app.js?ver=1.2.3"></script>
4173                <img src="/wp-includes/images/blank.gif">"#,
4174        );
4175        assert_eq!(signals.len(), 4);
4176        assert!(signals.iter().any(|signal| {
4177            signal.identity() == &WordPressComponentIdentity::core()
4178                && signal.version() == Some("6.9.4")
4179                && signal.source() == WordPressEvidenceSource::GeneratorMetadata
4180        }));
4181        let plugin = signals
4182            .iter()
4183            .find(|signal| signal.identity().slug() == "cache-tool")
4184            .unwrap();
4185        assert_eq!(plugin.version(), None, "asset queries are not versions");
4186    }
4187
4188    #[test]
4189    fn fingerprint_candidates_are_only_observed_script_and_stylesheet_resources() {
4190        let application = origin();
4191        let observation = extract_wordpress_entry_observation_with_asset_fingerprints(
4192            &WordPressDiscoveryContext::new(application.clone(), None),
4193            &application,
4194            r#"<script src="/wp-content/plugins/cache-tool/assets/app.js?ver=cache-42"></script>
4195                <link rel="stylesheet preload" href="/wp-content/themes/child/assets/site.css">
4196                <link rel="icon" href="/wp-content/plugins/cache-tool/assets/icon.css">
4197                <img src="/wp-content/plugins/cache-tool/assets/image.js">
4198                <script src="/wp-content/plugins/cache-tool/assets/bad.js?ver=1&amp;x=2"></script>
4199                <script src="https://other.test/wp-content/plugins/cache-tool/assets/foreign.js"></script>"#,
4200            &WordPressRestIndexAdvertisement::Missing,
4201            true,
4202            false,
4203            true,
4204        );
4205
4206        let actual = observation
4207            .asset_fingerprint_candidates
4208            .iter()
4209            .map(|candidate| {
4210                (
4211                    candidate.component().kind(),
4212                    candidate.component().slug(),
4213                    candidate.relative_path(),
4214                    candidate.target_url().as_str(),
4215                )
4216            })
4217            .collect::<BTreeSet<_>>();
4218        assert_eq!(
4219            actual,
4220            BTreeSet::from([
4221                (
4222                    WordPressComponentKind::Plugin,
4223                    "cache-tool",
4224                    "assets/app.js",
4225                    "https://example.test/wp-content/plugins/cache-tool/assets/app.js?ver=cache-42",
4226                ),
4227                (
4228                    WordPressComponentKind::Theme,
4229                    "child",
4230                    "assets/site.css",
4231                    "https://example.test/wp-content/themes/child/assets/site.css",
4232                ),
4233            ])
4234        );
4235    }
4236
4237    #[test]
4238    fn generator_that_withholds_its_version_retains_only_the_core_hint() {
4239        let signals =
4240            extract_wordpress_signals(&origin(), r#"<meta name="generator" content="WordPress">"#);
4241
4242        assert_eq!(signals.len(), 1);
4243        assert_eq!(signals[0].identity(), &WordPressComponentIdentity::core());
4244        assert_eq!(signals[0].version(), None);
4245        assert_eq!(
4246            signals[0].source(),
4247            WordPressEvidenceSource::GeneratorMetadata
4248        );
4249    }
4250
4251    #[test]
4252    fn prose_comments_scripts_anchors_and_foreign_assets_are_not_signals() {
4253        let signals = extract_wordpress_signals(
4254            &origin(),
4255            r#"WordPress 6.9.4<!-- <meta name='generator' content='WordPress 6.9.4'> -->
4256                <script>const x='/wp-content/plugins/not-installed/app.js';</script>
4257                <a href="/wp-content/plugins/linked-only/readme.txt">link</a>
4258                <link rel="canonical" href="/wp-content/plugins/canonical-only/readme.txt">
4259                <link rel="alternate" href="/wp-content/themes/feed-only/feed.xml">
4260                <script src="https://cdn.invalid/wp-content/plugins/foreign/app.js"></script>
4261                <meta name="description" content="WordPress 6.9.4">"#,
4262        );
4263        assert!(signals.is_empty());
4264    }
4265
4266    #[test]
4267    fn malformed_generator_and_component_paths_are_ignored() {
4268        let signals = extract_wordpress_signals(
4269            &origin(),
4270            r#"<meta name="generator" content="Powered by WordPress 6.9.4">
4271                <script src="/prefix/wp-content/plugins//app.js"></script>
4272                <script src="/wp-content/plugins/Word Press/app.js"></script>"#,
4273        );
4274        assert!(signals.is_empty());
4275    }
4276
4277    #[test]
4278    fn subpath_wordpress_sequences_are_not_bound_to_the_exact_root() {
4279        let signals = extract_wordpress_signals(
4280            &origin(),
4281            r#"<script src="/blog/wp-content/plugins/nested/app.js"></script>
4282                <link rel="stylesheet" href="/assets/wp-content/themes/nested/style.css">
4283                <img src="/assets/wp-includes/images/blank.gif">
4284                <script src="/nested/wp-admin/load-scripts.php"></script>"#,
4285        );
4286
4287        assert!(signals.is_empty());
4288    }
4289
4290    #[test]
4291    fn effective_base_and_root_asset_form_an_ambiguous_root_layout() {
4292        let signals = extract_wordpress_signals(
4293            &origin(),
4294            r#"<base href="/blog/">
4295                <script src="wp-content/plugins/nested/app.js"></script>
4296                <link rel="stylesheet" href="/wp-content/themes/root-theme/style.css">"#,
4297        );
4298
4299        assert!(signals.is_empty());
4300    }
4301
4302    #[test]
4303    fn whitespace_padded_references_are_not_normalized_into_discovery_authority() {
4304        let selection = extract_wordpress_discovery_seeds(
4305            &origin(),
4306            r#"<link rel="https://api.w.org/" href=" /wp-json/">
4307                <script src=" /wp-content/plugins/padded/app.js"></script>
4308                <link rel="stylesheet" href="/wp-content/themes/padded/style.css ">"#,
4309            &WordPressRestIndexAdvertisement::Missing,
4310        );
4311        assert!(selection.seeds().is_empty());
4312
4313        let signals = extract_wordpress_signals(
4314            &origin(),
4315            r#"<base href=" /blog/">
4316                <script src="wp-content/plugins/padded/app.js"></script>"#,
4317        );
4318        assert!(signals.is_empty());
4319    }
4320
4321    #[test]
4322    fn distinct_header_and_html_rest_roots_are_rejected_as_ambiguous() {
4323        let rest = WordPressRestIndexAdvertisement::Candidate(
4324            Url::parse("https://example.test/index.php?rest_route=/").unwrap(),
4325        );
4326        let selection = extract_wordpress_discovery_seeds(
4327            &origin(),
4328            r#"<link rel="https://api.w.org/" href="/wp-json/">
4329                <script src="/wp-content/plugins/z-cache/assets/app.js?ver=secret"></script>
4330                <link rel="stylesheet" href="/wp-content/themes/a-theme/assets/app.css">
4331                <script src="https://foreign.invalid/wp-content/plugins/foreign/app.js"></script>
4332                <img src="/prefix/wp-content/themes/nested/image.png">"#,
4333            &rest,
4334        );
4335
4336        let seeds = selection.seeds();
4337        assert_eq!(seeds.len(), 1);
4338        assert_eq!(seeds[0].kind(), WordPressDiscoverySourceKind::RestIndex);
4339        assert_eq!(
4340            seeds[0].preflight_outcome(),
4341            Some(WordPressDiscoverySourceOutcome::InvalidAdvertisement)
4342        );
4343        assert!(seeds.iter().all(|seed| seed.url().query().is_none()
4344            || seed.kind() == WordPressDiscoverySourceKind::RestIndex));
4345    }
4346
4347    #[test]
4348    fn equivalent_header_and_html_rest_roots_deduplicate() {
4349        let rest = WordPressRestIndexAdvertisement::Candidate(
4350            Url::parse("https://example.test/wp-json/").unwrap(),
4351        );
4352        let selection = extract_wordpress_discovery_seeds(
4353            &origin(),
4354            r#"<link rel="https://api.w.org/" href="/wp-json/">"#,
4355            &rest,
4356        );
4357
4358        assert_eq!(selection.seeds().len(), 1);
4359        assert_eq!(selection.seeds()[0].preflight_outcome(), None);
4360        assert_eq!(
4361            selection.seeds()[0].url().as_str(),
4362            "https://example.test/wp-json/"
4363        );
4364    }
4365
4366    #[test]
4367    fn html_rest_relation_uses_the_same_exact_raw_query_contract_as_headers() {
4368        for query in ["rest_route=/", "rest_route=%2F"] {
4369            let selection = extract_wordpress_discovery_seeds(
4370                &origin(),
4371                &format!(r#"<link rel="https://api.w.org/" href="/index.php?{query}">"#),
4372                &WordPressRestIndexAdvertisement::Missing,
4373            );
4374            assert_eq!(selection.seeds().len(), 1, "accepted query: {query}");
4375            assert_eq!(
4376                selection.seeds()[0].kind(),
4377                WordPressDiscoverySourceKind::RestIndex
4378            );
4379        }
4380
4381        for query in [
4382            "rest_route=%2f",
4383            "%72est_route=%2F",
4384            "rest%5Froute=%2F",
4385            "rest_route=%252F",
4386            "rest_route=%2F&token=secret",
4387            "rest_route=%2F&rest_route=%2F",
4388            "REST_ROUTE=%2F",
4389        ] {
4390            let selection = extract_wordpress_discovery_seeds(
4391                &origin(),
4392                &format!(r#"<link rel="https://api.w.org/" href="/index.php?{query}">"#),
4393                &WordPressRestIndexAdvertisement::Missing,
4394            );
4395            assert!(selection.seeds().is_empty(), "rejected query: {query}");
4396        }
4397    }
4398
4399    #[test]
4400    fn discovery_rejects_ambiguous_paths_and_nested_installations() {
4401        let selection = extract_wordpress_discovery_seeds(
4402            &origin(),
4403            r#"<link rel="https://api.w.org/" href="/index.php?rest_route=/&token=secret">
4404                <script src="/blog/wp-content/plugins/nested/app.js"></script>
4405                <script src="/shop/wp-content/plugins/nested/app.js"></script>
4406                <script src="/wp-content/plugins/../escape/app.js"></script>
4407                <script src="/wp-content/plugins/%2fescape/app.js"></script>
4408                <script src="/wp-content/plugins/decoy/%2e%2e/escape/app.js"></script>
4409                <script src="/wp-content/plugins/decoy/%252e%252e/escape/app.js"></script>
4410                <script src="\\wp-content\\plugins\\escape\\app.js"></script>"#,
4411            &WordPressRestIndexAdvertisement::InvalidOrAmbiguous,
4412        );
4413
4414        let seeds = selection.seeds();
4415        assert_eq!(seeds.len(), 1);
4416        assert_eq!(seeds[0].kind(), WordPressDiscoverySourceKind::RestIndex);
4417        assert_eq!(
4418            seeds[0].preflight_outcome(),
4419            Some(WordPressDiscoverySourceOutcome::InvalidAdvertisement)
4420        );
4421    }
4422
4423    #[test]
4424    fn discovery_candidate_cap_is_order_independent_and_explicit() {
4425        fn html(indices: impl IntoIterator<Item = usize>) -> String {
4426            indices
4427                .into_iter()
4428                .map(|index| {
4429                    format!(
4430                        r#"<script src="/wp-content/plugins/plugin-{index:02}/asset.js"></script>"#
4431                    )
4432                })
4433                .collect()
4434        }
4435
4436        let ascending = extract_wordpress_discovery_seeds(
4437            &origin(),
4438            &html(0..40),
4439            &WordPressRestIndexAdvertisement::Missing,
4440        );
4441        let descending = extract_wordpress_discovery_seeds(
4442            &origin(),
4443            &html((0..40).rev()),
4444            &WordPressRestIndexAdvertisement::Missing,
4445        );
4446
4447        assert_eq!(ascending.omitted_candidate_count, 8);
4448        assert_eq!(descending.omitted_candidate_count, 8);
4449        assert_eq!(ascending.seeds, descending.seeds);
4450        assert_eq!(ascending.seeds.len(), MAX_WORDPRESS_DISCOVERY_CANDIDATES);
4451        assert_eq!(
4452            ascending
4453                .seeds
4454                .first()
4455                .unwrap()
4456                .component_identity()
4457                .unwrap()
4458                .slug(),
4459            "plugin-00"
4460        );
4461        assert_eq!(
4462            ascending
4463                .seeds
4464                .last()
4465                .unwrap()
4466                .component_identity()
4467                .unwrap()
4468                .slug(),
4469            "plugin-31"
4470        );
4471
4472        let with_duplicates = extract_wordpress_discovery_seeds(
4473            &origin(),
4474            &format!("{}{}", html(0..40), html(std::iter::repeat_n(39, 16))),
4475            &WordPressRestIndexAdvertisement::Missing,
4476        );
4477        assert_eq!(with_duplicates.omitted_candidate_count, 8);
4478        assert_eq!(with_duplicates.seeds, ascending.seeds);
4479    }
4480
4481    #[test]
4482    fn discovery_candidate_retention_reserves_every_closed_request_quota() {
4483        let themes = (0..31)
4484            .map(|index| {
4485                format!(
4486                    r#"<link rel="stylesheet" href="/wp-content/themes/theme-{index:02}/asset.css">"#
4487                )
4488            })
4489            .collect::<String>();
4490        let plugins = (0..8)
4491            .map(|index| {
4492                format!(r#"<script src="/wp-content/plugins/plugin-{index:02}/asset.js"></script>"#)
4493            })
4494            .collect::<String>();
4495        let selection = extract_wordpress_discovery_seeds(
4496            &origin(),
4497            &format!("{themes}{plugins}"),
4498            &WordPressRestIndexAdvertisement::Candidate(
4499                Url::parse("https://example.test/wp-json/").unwrap(),
4500            ),
4501        );
4502
4503        assert_eq!(selection.seeds().len(), MAX_WORDPRESS_DISCOVERY_CANDIDATES);
4504        assert_eq!(selection.omitted_candidate_count, 8);
4505        assert_eq!(
4506            selection
4507                .seeds()
4508                .iter()
4509                .filter(|seed| seed.kind() == WordPressDiscoverySourceKind::RestIndex)
4510                .count(),
4511            1
4512        );
4513        assert!(
4514            selection
4515                .seeds()
4516                .iter()
4517                .filter(|seed| seed.kind() == WordPressDiscoverySourceKind::ThemeStylesheet)
4518                .count()
4519                >= usize::from(MAX_WORDPRESS_DISCOVERY_THEME_REQUESTS)
4520        );
4521        assert_eq!(
4522            selection
4523                .seeds()
4524                .iter()
4525                .filter(|seed| seed.kind() == WordPressDiscoverySourceKind::PluginReadme)
4526                .count(),
4527            usize::from(MAX_WORDPRESS_DISCOVERY_PLUGIN_REQUESTS)
4528        );
4529    }
4530
4531    #[test]
4532    fn discovery_collector_counts_distinct_omissions_across_repeated_observations() {
4533        let html = (0..40)
4534            .map(|index| {
4535                format!(r#"<script src="/wp-content/plugins/plugin-{index:02}/asset.js"></script>"#)
4536            })
4537            .collect::<String>();
4538        let collector = WordPressSignalCollector::default();
4539        for _ in 0..2 {
4540            collector.record(
4541                Vec::new(),
4542                extract_wordpress_discovery_seeds(
4543                    &origin(),
4544                    &html,
4545                    &WordPressRestIndexAdvertisement::Missing,
4546                ),
4547                Vec::new(),
4548            );
4549        }
4550        let (seeds, _, omitted, fingerprints, _) = collector.discovery_snapshot().unwrap();
4551        assert_eq!(seeds.len(), MAX_WORDPRESS_DISCOVERY_CANDIDATES);
4552        assert_eq!(omitted, 8);
4553        assert_eq!(fingerprints.len(), 40);
4554    }
4555
4556    #[test]
4557    fn discovery_candidate_identity_accounting_is_bounded() {
4558        let mut selection = WordPressDiscoverySeedSelection::default();
4559        for index in 0..=MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES {
4560            let slug = format!("plugin-{index:04}");
4561            let identity =
4562                WordPressComponentIdentity::new(WordPressComponentKind::Plugin, &slug).unwrap();
4563            let url = Url::parse(&format!(
4564                "https://example.test/wp-content/plugins/{slug}/readme.txt"
4565            ))
4566            .unwrap();
4567            selection.insert(WordPressDiscoverySeed::component(url, identity, 0));
4568        }
4569        assert!(selection.candidate_identity_limit_exceeded);
4570        assert_eq!(
4571            selection.seen_candidate_fingerprints.len(),
4572            MAX_WORDPRESS_DISCOVERY_CANDIDATE_IDENTITIES
4573        );
4574    }
4575
4576    #[test]
4577    fn changing_root_rest_advertisements_collapse_to_one_invalid_candidate() {
4578        let collector = WordPressSignalCollector::default();
4579        let mut first = WordPressDiscoverySeedSelection::default();
4580        first.insert(WordPressDiscoverySeed::rest_index(
4581            Url::parse("https://example.test/wp-json/").unwrap(),
4582        ));
4583        collector.record(Vec::new(), first, Vec::new());
4584
4585        let mut second = WordPressDiscoverySeedSelection::default();
4586        second.insert(WordPressDiscoverySeed::rest_index(
4587            Url::parse("https://example.test/?rest_route=/").unwrap(),
4588        ));
4589        collector.record(Vec::new(), second, Vec::new());
4590
4591        let (seeds, _, omitted, fingerprints, _) = collector.discovery_snapshot().unwrap();
4592        assert_eq!(seeds.len(), 1);
4593        assert_eq!(omitted, 0);
4594        assert_eq!(fingerprints.len(), 1);
4595        assert_eq!(seeds[0].kind(), WordPressDiscoverySourceKind::RestIndex);
4596        assert_eq!(
4597            seeds[0].preflight_outcome(),
4598            Some(WordPressDiscoverySourceOutcome::InvalidAdvertisement)
4599        );
4600    }
4601}