1use 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
80pub const WORDPRESS_REVIEW_CAPABILITY_ID: &str = "technology.wordpress-surface-observed@1";
83pub 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#[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 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#[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 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 pub const fn discovery(&self) -> Option<&WebAssessmentWordPressDiscoveryAudit> {
1801 self.discovery.as_ref()
1802 }
1803
1804 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#[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#[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#[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
3179fn 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 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&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}