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