1use std::{
9 collections::{BTreeMap, BTreeSet},
10 sync::{Arc, Mutex},
11};
12
13use sha2::{Digest, Sha256};
14use termivar_core::{
15 ConfidenceScore, DerivationAlgorithm, EntityId, Evidence, EvidenceDerivation, EvidenceId,
16 EvidenceKind, EvidenceSource, EvidenceValue, HttpEvidencePredicate, KnowledgePredicate,
17 PredicateDescriptor,
18};
19use thiserror::Error;
20use tokio_util::sync::CancellationToken;
21use url::Url;
22
23use crate::{
24 http_evidence::{
25 wordpress_asset_request_binding_value, CompleteHttpResponseObservation, HttpEvidenceError,
26 HttpProbeMethod, HttpRequestBrokerError, WordPressAssetRequestDescriptor,
27 WordPressMetadataRequestSource, WORDPRESS_ASSET_FINGERPRINT_POLICY_ID,
28 WORDPRESS_ASSET_NOMINATION_COMPONENT, WORDPRESS_ASSET_NOMINATION_METHOD,
29 WORDPRESS_ASSET_NOMINATION_NAMESPACE, WORDPRESS_ASSET_NOMINATION_PREDICATE,
30 },
31 wordpress_review::{
32 match_wordpress_asset_fingerprint_component, valid_wordpress_asset_fingerprint_path,
33 WordPressAssetFingerprintCatalog, WordPressAssetFingerprintComponentMatch,
34 WordPressAssetFingerprintError, WordPressComponentIdentity, WordPressComponentKind,
35 WordPressObservedAssetFingerprint, MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES,
36 },
37 DecisionActionOrigin, DecisionExecutionLimits, DecisionExecutionStage, KnowledgeBase,
38};
39
40use super::{
41 wordpress_discovery::{opaque_url_reference, WordPressDiscoveryAssociation},
42 SharedWebRuntimeAuthority,
43};
44
45#[cfg(feature = "secret-exposure-review")]
46use super::secret_exposure::{
47 is_secret_exposure_response_lineage, secret_exposure_response_parent_ids,
48 RESPONSE_BODY_LINEAGE, SECRET_EXPOSURE_NAMESPACE,
49};
50
51pub const WORDPRESS_ASSET_FINGERPRINT_ACTION_ID: &str = "web.review.wordpress.asset-fingerprint@1";
52pub const MAX_WORDPRESS_ASSET_FINGERPRINT_REQUESTS: u8 = 4;
53pub const MAX_WORDPRESS_ASSET_FINGERPRINT_COMPONENTS: usize = 2;
54pub const MAX_WORDPRESS_ASSET_FINGERPRINT_PATHS_PER_COMPONENT: usize = 2;
55const MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATES: usize = 32;
56pub(super) const MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES: usize = 256;
57const MAX_WORDPRESS_ASSET_FINGERPRINT_SOURCE_EVIDENCE: usize = 8;
58const MAX_WORDPRESS_ASSET_FINGERPRINT_SOURCE_PAGES: usize = 4;
59const MAX_WORDPRESS_ASSET_FINGERPRINT_OBSERVATIONS_PER_PATH: usize = 8;
60const MAX_RAW_ASSET_REFERENCE_BYTES: usize = 2_048;
61const WORDPRESS_TOTAL_REQUEST_LIMIT: u8 = 12;
62const WORDPRESS_TOTAL_RESPONSE_BYTES: u64 = 2 * 1_024 * 1_024;
63const ASSET_EVIDENCE_NAMESPACE: &str = "web.wordpress-asset-fingerprint";
64const ASSET_REPRESENTATION_PREDICATE: &str = "identity-content-bytes-observed";
65const ASSET_RESPONSE_DERIVATION: &str = "wordpress-asset-identity-content-bytes";
66const ASSET_FETCHED_ROOT_DERIVATION: &str = "wordpress-asset-owned-get-root-binding";
67
68#[derive(Clone, Debug, Eq, Error, PartialEq)]
69pub enum WordPressAssetFingerprintRuntimeError {
70 #[error("observed WordPress asset violated its frozen admission contract")]
71 InvalidCandidate,
72 #[error("WordPress asset fingerprint evidence model is inconsistent")]
73 EvidenceModel,
74 #[error("WordPress asset fingerprint evidence could not be committed")]
75 EvidenceCommit,
76 #[error("WordPress asset fingerprint matching failed: {0}")]
77 Match(#[from] WordPressAssetFingerprintError),
78}
79
80#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
84pub(super) struct WordPressObservedAssetCandidate {
85 application_url: Url,
86 role_base_url: Url,
87 document_url: Url,
88 target_url: Url,
89 component: WordPressComponentIdentity,
90 association: WordPressDiscoveryAssociation,
91 relative_path: String,
92 source_page_reference: String,
93}
94
95impl WordPressObservedAssetCandidate {
96 #[allow(clippy::too_many_arguments)]
97 pub(super) fn from_html_reference(
98 application_url: &Url,
99 role_base_url: &Url,
100 component: WordPressComponentIdentity,
101 association: WordPressDiscoveryAssociation,
102 document_url: &Url,
103 resolution_base: &Url,
104 raw_reference: &str,
105 resolved_url: &Url,
106 relative_path: impl Into<String>,
107 source_page_reference: impl Into<String>,
108 ) -> Result<Self, WordPressAssetFingerprintRuntimeError> {
109 let relative_path = relative_path.into();
110 let source_page_reference = source_page_reference.into();
111 if raw_reference.is_empty()
112 || raw_reference.len() > MAX_RAW_ASSET_REFERENCE_BYTES
113 || !raw_reference.is_ascii()
114 || raw_reference != raw_reference.trim()
115 || raw_reference.starts_with("//")
116 || raw_reference.bytes().any(|byte| byte.is_ascii_control())
117 || !crate::http_evidence::wordpress_reference_path_is_safe(raw_reference)
118 || !valid_wordpress_asset_fingerprint_path(&relative_path)
119 || document_url.origin() != application_url.origin()
120 || resolution_base.origin() != application_url.origin()
121 || !resolution_base
122 .join(raw_reference)
123 .is_ok_and(|joined| joined == *resolved_url)
124 || source_page_reference
125 != opaque_url_reference("wordpress-discovery-page", document_url)
126 {
127 return Err(WordPressAssetFingerprintRuntimeError::InvalidCandidate);
128 }
129 WordPressAssetRequestDescriptor::validate_candidate_shape(
130 WORDPRESS_ASSET_FINGERPRINT_POLICY_ID,
131 application_url,
132 role_base_url,
133 resolved_url,
134 &component,
135 asset_request_source(association)?,
136 &relative_path,
137 1,
138 )
139 .map_err(|_| WordPressAssetFingerprintRuntimeError::InvalidCandidate)?;
140 Ok(Self {
141 application_url: application_url.clone(),
142 role_base_url: role_base_url.clone(),
143 document_url: document_url.clone(),
144 target_url: resolved_url.clone(),
145 component,
146 association,
147 relative_path,
148 source_page_reference,
149 })
150 }
151
152 #[cfg(test)]
153 pub(super) fn target_url(&self) -> &Url {
154 &self.target_url
155 }
156
157 pub(super) fn component(&self) -> &WordPressComponentIdentity {
158 &self.component
159 }
160
161 pub(super) fn relative_path(&self) -> &str {
162 &self.relative_path
163 }
164}
165
166#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
167struct AssetPathKey {
168 component: WordPressComponentIdentity,
169 relative_path: String,
170}
171
172impl AssetPathKey {
173 fn of(candidate: &WordPressObservedAssetCandidate) -> Self {
174 Self {
175 component: candidate.component.clone(),
176 relative_path: candidate.relative_path.clone(),
177 }
178 }
179}
180
181#[derive(Clone)]
182struct CommittedCandidate {
183 candidate: WordPressObservedAssetCandidate,
184 root_evidence_ids: BTreeSet<EvidenceId>,
185 source_page_references: BTreeSet<String>,
186}
187
188#[derive(Clone)]
189struct PendingCandidateBatch {
190 candidates: Vec<WordPressObservedAssetCandidate>,
191 parent_evidence_ids: Vec<EvidenceId>,
192 source_limit_exceeded: bool,
193}
194
195#[derive(Clone)]
196struct PendingRepresentation {
197 selected: WordPressSelectedObservedAsset,
198 observation: WordPressObservedAssetFingerprint,
199 endpoint_evidence_id: EvidenceId,
200 parent_evidence_ids: Vec<EvidenceId>,
201 evidence_value: String,
202 media_type: String,
203}
204
205#[derive(Clone)]
206struct CommittedRepresentation {
207 selected: WordPressSelectedObservedAsset,
208 observation: WordPressObservedAssetFingerprint,
209 root_evidence_id: EvidenceId,
210}
211
212struct CollectedFingerprintState {
213 catalog: Arc<WordPressAssetFingerprintCatalog>,
214 pending_candidates: BTreeMap<String, PendingCandidateBatch>,
215 candidates: BTreeMap<AssetPathKey, BTreeMap<String, CommittedCandidate>>,
216 seen_candidate_fingerprints: BTreeSet<[u8; 32]>,
217 observed_variant_fingerprints: BTreeMap<AssetPathKey, BTreeSet<[u8; 32]>>,
218 seen_resource_keys: BTreeSet<AssetPathKey>,
219 candidate_count: u64,
220 pending_representations: BTreeMap<String, PendingRepresentation>,
221 representations: Vec<CommittedRepresentation>,
222 ordinary_get_attempts: BTreeSet<String>,
223 ordinary_get_attempt_history_complete: bool,
224 candidate_identity_limit_exceeded: bool,
225 source_page_limit_exceeded: bool,
226}
227
228#[derive(Clone)]
231pub(super) struct WordPressAssetFingerprintCollector(Arc<Mutex<CollectedFingerprintState>>);
232
233impl WordPressAssetFingerprintCollector {
234 pub(super) fn new(catalog: Arc<WordPressAssetFingerprintCatalog>) -> Self {
235 Self(Arc::new(Mutex::new(CollectedFingerprintState {
236 catalog,
237 pending_candidates: BTreeMap::new(),
238 candidates: BTreeMap::new(),
239 seen_candidate_fingerprints: BTreeSet::new(),
240 observed_variant_fingerprints: BTreeMap::new(),
241 seen_resource_keys: BTreeSet::new(),
242 candidate_count: 0,
243 pending_representations: BTreeMap::new(),
244 representations: Vec::new(),
245 ordinary_get_attempts: BTreeSet::new(),
246 ordinary_get_attempt_history_complete: true,
247 candidate_identity_limit_exceeded: false,
248 source_page_limit_exceeded: false,
249 })))
250 }
251
252 pub(super) fn stage_candidates(
253 &self,
254 document_url: &Url,
255 mut candidates: Vec<WordPressObservedAssetCandidate>,
256 parent_evidence_ids: Vec<EvidenceId>,
257 source_limit_exceeded: bool,
258 ) -> Result<(), WordPressAssetFingerprintRuntimeError> {
259 if candidates.is_empty() && !source_limit_exceeded {
260 return Ok(());
261 }
262 candidates.sort();
263 candidates.dedup();
264 let source_limit_exceeded = source_limit_exceeded
265 || candidates.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES;
266 if candidates.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES {
267 candidates.truncate(MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES);
268 }
269 if candidates
270 .iter()
271 .any(|candidate| candidate.document_url != *document_url)
272 || parent_evidence_ids.is_empty()
273 || parent_evidence_ids.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_SOURCE_EVIDENCE
274 || parent_evidence_ids.iter().collect::<BTreeSet<_>>().len()
275 != parent_evidence_ids.len()
276 {
277 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
278 }
279 let mut state = self
280 .0
281 .lock()
282 .unwrap_or_else(|poisoned| poisoned.into_inner());
283 if state
284 .pending_candidates
285 .insert(
286 document_url.as_str().to_owned(),
287 PendingCandidateBatch {
288 candidates,
289 parent_evidence_ids,
290 source_limit_exceeded,
291 },
292 )
293 .is_some()
294 {
295 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
296 }
297 Ok(())
298 }
299
300 pub(super) fn commit_staged_candidates(
304 &self,
305 knowledge: &KnowledgeBase,
306 root_subject: &EntityId,
307 page_subject: &EntityId,
308 page_url: &Url,
309 ) -> Result<(), WordPressAssetFingerprintRuntimeError> {
310 let pending = {
311 let mut state = self
312 .0
313 .lock()
314 .unwrap_or_else(|poisoned| poisoned.into_inner());
315 state.pending_candidates.remove(page_url.as_str())
316 };
317 let Some(mut pending) = pending else {
318 return Ok(());
319 };
320 if !committed_html_response_is_valid(
321 knowledge,
322 &pending.parent_evidence_ids,
323 page_subject,
324 page_url,
325 ) {
326 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
327 }
328
329 if pending.source_limit_exceeded {
330 let mut state = self
331 .0
332 .lock()
333 .unwrap_or_else(|poisoned| poisoned.into_inner());
334 state.candidate_identity_limit_exceeded = true;
335 return Ok(());
336 }
337
338 let mut state = self
339 .0
340 .lock()
341 .unwrap_or_else(|poisoned| poisoned.into_inner());
342 let catalog = state.catalog.clone();
343 pending.candidates.retain(|candidate| {
344 catalog
345 .component(candidate.component())
346 .is_some_and(|component| {
347 component
348 .releases()
349 .iter()
350 .any(|release| release.file(candidate.relative_path()).is_some())
351 })
352 });
353 if pending.candidates.is_empty() {
354 return Ok(());
355 }
356 if !candidate_identity_capacity_allows(
357 &state.seen_candidate_fingerprints,
358 &pending.candidates,
359 ) {
360 state.candidate_identity_limit_exceeded = true;
361 return Ok(());
362 }
363 pending.candidates.retain(|candidate| {
364 !state
365 .seen_candidate_fingerprints
366 .contains(&candidate_fingerprint(candidate))
367 });
368 if pending.candidates.is_empty() {
369 return Ok(());
370 }
371 if !source_page_capacity_allows(&state.candidates, &pending.candidates) {
372 state.source_page_limit_exceeded = true;
373 return Ok(());
374 }
375 let bindings = pending
376 .candidates
377 .iter()
378 .map(|candidate| candidate_binding_evidence(root_subject, candidate))
379 .collect::<Result<Vec<_>, _>>()?;
380 let binding_ids = bindings
381 .iter()
382 .map(|binding| binding.id().clone())
383 .collect::<Vec<_>>();
384 knowledge
385 .insert_evidence_batch(bindings)
386 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceCommit)?;
387 for (candidate, binding_id) in pending.candidates.into_iter().zip(binding_ids) {
388 let key = AssetPathKey::of(&candidate);
389 let candidate_fingerprint = candidate_fingerprint(&candidate);
390 if !state
391 .seen_candidate_fingerprints
392 .contains(&candidate_fingerprint)
393 {
394 state
395 .seen_candidate_fingerprints
396 .insert(candidate_fingerprint);
397 }
398 let target_variant_fingerprint = target_variant_fingerprint(&candidate);
399 state
400 .observed_variant_fingerprints
401 .entry(key.clone())
402 .or_default()
403 .insert(target_variant_fingerprint);
404 if state.seen_resource_keys.insert(key.clone()) {
405 state.candidate_count = state
406 .candidate_count
407 .checked_add(1)
408 .ok_or(WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
409 }
410 let target = candidate.target_url.as_str().to_owned();
411 if let Some(existing) = state
412 .candidates
413 .get_mut(&key)
414 .and_then(|variants| variants.get_mut(&target))
415 {
416 if existing.candidate.application_url != candidate.application_url
417 || existing.candidate.role_base_url != candidate.role_base_url
418 || existing.candidate.association != candidate.association
419 {
420 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
421 }
422 existing.root_evidence_ids.insert(binding_id.clone());
423 existing
424 .source_page_references
425 .insert(candidate.source_page_reference.clone());
426 continue;
427 }
428 let retained_candidate_count =
429 state.candidates.values().map(BTreeMap::len).sum::<usize>();
430 if retained_candidate_count == MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATES {
431 let greatest = state
432 .candidates
433 .iter()
434 .next_back()
435 .and_then(|(key, variants)| {
436 variants
437 .keys()
438 .next_back()
439 .map(|target| (key.clone(), target.clone()))
440 });
441 let incoming = (key.clone(), target.clone());
442 if greatest
443 .as_ref()
444 .is_none_or(|greatest| incoming >= *greatest)
445 {
446 continue;
447 }
448 if let Some((greatest_key, greatest_target)) = greatest {
449 let remove_key = if let Some(variants) = state.candidates.get_mut(&greatest_key)
450 {
451 variants.remove(&greatest_target);
452 variants.is_empty()
453 } else {
454 false
455 };
456 if remove_key {
457 state.candidates.remove(&greatest_key);
458 }
459 }
460 }
461 state.candidates.entry(key).or_default().insert(
462 target,
463 CommittedCandidate {
464 source_page_references: BTreeSet::from([candidate
465 .source_page_reference
466 .clone()]),
467 candidate,
468 root_evidence_ids: BTreeSet::from([binding_id]),
469 },
470 );
471 }
472 Ok(())
473 }
474
475 pub(super) fn selected_observed_asset(
480 &self,
481 url: &Url,
482 ) -> Option<WordPressSelectedObservedAsset> {
483 let state = self
484 .0
485 .lock()
486 .unwrap_or_else(|poisoned| poisoned.into_inner());
487 selected_assets(&state)
488 .into_iter()
489 .find(|selected| selected.target_url() == url)
490 }
491
492 pub(super) fn mark_ordinary_get_attempted(
495 &self,
496 url: &Url,
497 ) -> Result<(), WordPressAssetFingerprintRuntimeError> {
498 let mut state = self
499 .0
500 .lock()
501 .unwrap_or_else(|poisoned| poisoned.into_inner());
502 if !state.ordinary_get_attempts.contains(url.as_str())
503 && state.ordinary_get_attempts.len()
504 == super::web_assessment::HARD_MAX_WEB_ASSESSMENT_SUBJECTS
505 {
506 state.ordinary_get_attempt_history_complete = false;
507 return Ok(());
508 }
509 state.ordinary_get_attempts.insert(url.as_str().to_owned());
510 Ok(())
511 }
512
513 pub(super) fn observe_selected_response(
517 &self,
518 observation: &CompleteHttpResponseObservation<'_>,
519 ) -> Result<Vec<Evidence>, HttpEvidenceError> {
520 let Some(selected) = self.selected_observed_asset(observation.requested_url()) else {
521 return Ok(Vec::new());
522 };
523 if observation.method() != HttpProbeMethod::Get
524 || observation.status() != 200
525 || !observation.response_final_url_matches_request()
526 || !observation.response_content_encoding_absent()
527 || !observation.response_content_length_consistent()
528 || !asset_media_type_is_supported(selected.relative_path(), observation.media_type())
529 {
530 return Ok(Vec::new());
531 }
532 let Some(body) = observation.complete_identity_content_body() else {
533 return Ok(Vec::new());
534 };
535 let byte_length = u64::try_from(body.len()).unwrap_or(u64::MAX);
536 if byte_length != observation.response_body_bytes()
537 || byte_length > MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES
538 || looks_like_html(body)
539 {
540 return Ok(Vec::new());
541 }
542 let parent_evidence_ids = complete_asset_parent_evidence_ids(observation)?;
543 if parent_evidence_ids.len() != 8
544 || parent_evidence_ids.iter().collect::<BTreeSet<_>>().len()
545 != parent_evidence_ids.len()
546 {
547 return Err(HttpEvidenceError::AssessmentObserverInvariant {
548 invariant: "asset fingerprint requires eight distinct complete-response parents",
549 });
550 }
551 let digest: [u8; 32] = Sha256::digest(body).into();
552 let observed = WordPressObservedAssetFingerprint::new(
553 selected.component().clone(),
554 selected.relative_path(),
555 byte_length,
556 digest,
557 )
558 .map_err(|_| HttpEvidenceError::AssessmentObserverInvariant {
559 invariant: "asset fingerprint matcher input must remain bounded",
560 })?;
561 let value = representation_evidence_value(
562 selected.resource_reference(),
563 observed.byte_length(),
564 observed.sha256(),
565 );
566 let derivation = EvidenceDerivation::new(
567 parent_evidence_ids.iter().cloned(),
568 DerivationAlgorithm::new(ASSET_RESPONSE_DERIVATION, 1)?,
569 )?;
570 let source = EvidenceSource::new(
571 "wordpress.asset-fingerprint",
572 "committed-ordinary-get-identity-bytes",
573 )?
574 .with_correlation_id(observation.case_id())?;
575 let evidence = Evidence::new(
576 observation.subject().clone(),
577 EvidenceKind::Content,
578 KnowledgePredicate::new(ASSET_EVIDENCE_NAMESPACE, ASSET_REPRESENTATION_PREDICATE)?,
579 EvidenceValue::Text(value.clone()),
580 source,
581 observation.reliability(),
582 )
583 .derived_from(derivation);
584 let pending = PendingRepresentation {
585 selected,
586 observation: observed,
587 endpoint_evidence_id: evidence.id().clone(),
588 parent_evidence_ids,
589 evidence_value: value,
590 media_type: observation.media_type().unwrap_or_default().to_owned(),
591 };
592 let mut state = self
593 .0
594 .lock()
595 .unwrap_or_else(|poisoned| poisoned.into_inner());
596 if state
597 .pending_representations
598 .insert(observation.requested_url().as_str().to_owned(), pending)
599 .is_some()
600 {
601 return Err(HttpEvidenceError::AssessmentObserverInvariant {
602 invariant: "asset fingerprint response may be staged only once",
603 });
604 }
605 Ok(vec![evidence])
606 }
607
608 pub(super) fn commit_staged_response(
612 &self,
613 knowledge: &KnowledgeBase,
614 root_subject: &EntityId,
615 response_subject: &EntityId,
616 response_url: &Url,
617 ) -> Result<(), WordPressAssetFingerprintRuntimeError> {
618 let pending = {
619 let mut state = self
620 .0
621 .lock()
622 .unwrap_or_else(|poisoned| poisoned.into_inner());
623 state.pending_representations.remove(response_url.as_str())
624 };
625 let Some(pending) = pending else {
626 return Ok(());
627 };
628 if !committed_asset_response_is_valid(knowledge, &pending, response_subject, response_url) {
629 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
630 }
631 let mut state = self
632 .0
633 .lock()
634 .unwrap_or_else(|poisoned| poisoned.into_inner());
635 if state.representations.iter().any(|existing| {
636 existing.selected.target_url() == pending.selected.target_url()
637 && existing.observation == pending.observation
638 }) {
639 return Ok(());
640 }
641 if state
642 .representations
643 .iter()
644 .filter(|existing| {
645 existing.selected.component() == pending.selected.component()
646 && existing.selected.relative_path() == pending.selected.relative_path()
647 })
648 .count()
649 == MAX_WORDPRESS_ASSET_FINGERPRINT_OBSERVATIONS_PER_PATH
650 {
651 return Ok(());
656 }
657 let root_evidence = root_representation_binding_evidence(
658 root_subject,
659 &pending.selected,
660 &pending.observation,
661 &pending.endpoint_evidence_id,
662 "reused-complete-response",
663 )?;
664 let root_evidence_id = root_evidence.id().clone();
665 knowledge
666 .insert_evidence_batch(vec![root_evidence])
667 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceCommit)?;
668 state.representations.push(CommittedRepresentation {
669 selected: pending.selected,
670 observation: pending.observation,
671 root_evidence_id,
672 });
673 Ok(())
674 }
675
676 pub(super) fn plan(
677 &self,
678 ) -> Result<WordPressAssetFingerprintPlan, WordPressAssetFingerprintRuntimeError> {
679 let state = self
680 .0
681 .lock()
682 .unwrap_or_else(|poisoned| poisoned.into_inner());
683 if !state.pending_candidates.is_empty() || !state.pending_representations.is_empty() {
684 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
685 }
686 let selected_variants = selected_assets(&state);
687 let mut selected_paths =
688 BTreeMap::<AssetPathKey, Vec<WordPressSelectedObservedAsset>>::new();
689 for selected in selected_variants {
690 selected_paths
691 .entry(AssetPathKey {
692 component: selected.component.clone(),
693 relative_path: selected.relative_path.clone(),
694 })
695 .or_default()
696 .push(selected);
697 }
698 let selected_resource_count = selected_paths.len();
699 let candidate_path_counts = state.seen_resource_keys.iter().fold(
700 BTreeMap::<WordPressComponentIdentity, usize>::new(),
701 |mut counts, key| {
702 *counts.entry(key.component.clone()).or_default() += 1;
703 counts
704 },
705 );
706 let omitted_resource_count = usize::try_from(state.candidate_count)
707 .unwrap_or(usize::MAX)
708 .saturating_sub(selected_resource_count);
709 let selected = selected_paths
710 .into_iter()
711 .map(|(key, mut variants)| {
712 let observed_variant_count = state
713 .observed_variant_fingerprints
714 .get(&key)
715 .map(BTreeSet::len)
716 .ok_or(WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
717 if observed_variant_count < variants.len()
718 || observed_variant_count > MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES
719 {
720 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
721 }
722 variants
723 .sort_by(|left, right| left.target_url.as_str().cmp(right.target_url.as_str()));
724 let source_page_references = variants
725 .iter()
726 .flat_map(|variant| variant.source_page_references.iter().cloned())
727 .collect::<BTreeSet<_>>()
728 .into_iter()
729 .collect::<Vec<_>>();
730 if source_page_references.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_SOURCE_PAGES
731 || variants.iter().any(|variant| {
732 variant.source_evidence_ids.is_empty()
733 || variant.source_evidence_ids.len()
734 > MAX_WORDPRESS_ASSET_FINGERPRINT_SOURCE_PAGES
735 || variant.source_evidence_ids.len()
736 != variant.source_page_references.len()
737 })
738 {
739 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
740 }
741 for variant in &mut variants {
742 variant.source_page_references = source_page_references.clone();
743 }
744 Ok(WordPressAssetFingerprintPlannedPath {
745 variants,
746 observed_variant_count,
747 })
748 })
749 .collect::<Result<Vec<_>, _>>()?;
750 Ok(WordPressAssetFingerprintPlan {
751 catalog: state.catalog.clone(),
752 candidate_count: state.candidate_count,
753 selected_resource_count,
754 omitted_resource_count,
755 candidate_path_counts,
756 selected,
757 reused: state.representations.clone(),
758 ordinary_get_attempts: state.ordinary_get_attempts.clone(),
759 ordinary_get_attempt_history_complete: state.ordinary_get_attempt_history_complete,
760 candidate_identity_limit_exceeded: state.candidate_identity_limit_exceeded,
761 source_page_limit_exceeded: state.source_page_limit_exceeded,
762 })
763 }
764}
765
766#[derive(Clone)]
770pub(super) struct WordPressSelectedObservedAsset {
771 application_url: Url,
772 role_base_url: Url,
773 target_url: Url,
774 component: WordPressComponentIdentity,
775 association: WordPressDiscoveryAssociation,
776 relative_path: String,
777 resource_reference: String,
778 source_page_references: Vec<String>,
779 source_evidence_ids: Vec<EvidenceId>,
780}
781
782impl WordPressSelectedObservedAsset {
783 pub(super) fn target_url(&self) -> &Url {
784 &self.target_url
785 }
786
787 pub(super) fn component(&self) -> &WordPressComponentIdentity {
788 &self.component
789 }
790
791 pub(super) fn relative_path(&self) -> &str {
792 &self.relative_path
793 }
794
795 pub(super) fn resource_reference(&self) -> &str {
796 &self.resource_reference
797 }
798
799 fn request_descriptor(
800 &self,
801 root_subject: &EntityId,
802 knowledge: &KnowledgeBase,
803 ) -> Result<WordPressAssetRequestDescriptor, WordPressAssetFingerprintRuntimeError> {
804 WordPressAssetRequestDescriptor::from_committed_source_evidence(
805 WORDPRESS_ASSET_FINGERPRINT_POLICY_ID,
806 &self.application_url,
807 &self.role_base_url,
808 &self.target_url,
809 self.component.clone(),
810 asset_request_source(self.association)?,
811 self.relative_path.clone(),
812 root_subject,
813 self.source_evidence_ids.clone(),
814 knowledge,
815 )
816 .map_err(|_| WordPressAssetFingerprintRuntimeError::InvalidCandidate)
817 }
818}
819
820#[derive(Clone)]
821struct WordPressAssetFingerprintPlannedPath {
822 variants: Vec<WordPressSelectedObservedAsset>,
823 observed_variant_count: usize,
824}
825
826#[derive(Clone)]
827pub(super) struct WordPressAssetFingerprintPlan {
828 catalog: Arc<WordPressAssetFingerprintCatalog>,
829 candidate_count: u64,
830 selected_resource_count: usize,
831 omitted_resource_count: usize,
832 candidate_path_counts: BTreeMap<WordPressComponentIdentity, usize>,
833 selected: Vec<WordPressAssetFingerprintPlannedPath>,
834 reused: Vec<CommittedRepresentation>,
835 ordinary_get_attempts: BTreeSet<String>,
836 ordinary_get_attempt_history_complete: bool,
837 candidate_identity_limit_exceeded: bool,
838 source_page_limit_exceeded: bool,
839}
840
841#[derive(Clone, Copy, Debug, Eq, PartialEq)]
842pub enum WordPressAssetFingerprintAcquisition {
843 Reused,
844 Fetched,
845 NotAcquired,
846}
847
848impl WordPressAssetFingerprintAcquisition {
849 pub const fn as_str(self) -> &'static str {
850 match self {
851 Self::Reused => "reused",
852 Self::Fetched => "fetched",
853 Self::NotAcquired => "not_acquired",
854 }
855 }
856}
857
858#[derive(Clone, Copy, Debug, Eq, PartialEq)]
859pub enum WordPressAssetFingerprintResourceOutcome {
860 Observed,
861 ConflictingRepresentations,
862 OrdinaryResponseIneligible,
863 BudgetExhausted,
864 RequestFailed,
865 Cancelled,
866 DeadlineExceeded,
867 RateLimited,
868 Unauthorized,
869 NotFound,
870 RedirectObserved,
871 PartialOrIncomplete,
872 UnsupportedMediaType,
873 UnsupportedContentEncoding,
874 InconsistentContentLength,
875}
876
877impl WordPressAssetFingerprintResourceOutcome {
878 pub const fn as_str(self) -> &'static str {
879 match self {
880 Self::Observed => "observed",
881 Self::ConflictingRepresentations => "conflicting_representations",
882 Self::OrdinaryResponseIneligible => "ordinary_response_ineligible",
883 Self::BudgetExhausted => "budget_exhausted",
884 Self::RequestFailed => "request_failed",
885 Self::Cancelled => "cancelled",
886 Self::DeadlineExceeded => "deadline_exceeded",
887 Self::RateLimited => "rate_limited",
888 Self::Unauthorized => "unauthorized",
889 Self::NotFound => "not_found",
890 Self::RedirectObserved => "redirect_observed",
891 Self::PartialOrIncomplete => "partial_or_incomplete",
892 Self::UnsupportedMediaType => "unsupported_media_type",
893 Self::UnsupportedContentEncoding => "unsupported_content_encoding",
894 Self::InconsistentContentLength => "inconsistent_content_length",
895 }
896 }
897}
898
899#[derive(Clone, Copy, Debug, Eq, PartialEq)]
900pub enum WordPressAssetFingerprintStop {
901 Complete,
902 Cancelled,
903 DeadlineExceeded,
904 RequestLimit,
905 ResponseLimit,
906 RuntimeLimit,
907 RateLimited,
908}
909
910impl WordPressAssetFingerprintStop {
911 pub const fn as_str(self) -> &'static str {
912 match self {
913 Self::Complete => "complete",
914 Self::Cancelled => "cancelled",
915 Self::DeadlineExceeded => "deadline_exceeded",
916 Self::RequestLimit => "request_limit",
917 Self::ResponseLimit => "response_limit",
918 Self::RuntimeLimit => "runtime_limit",
919 Self::RateLimited => "rate_limited",
920 }
921 }
922}
923
924#[derive(Clone, Copy, Debug, Eq, PartialEq)]
925pub(super) struct WordPressAssetFingerprintAllowance {
926 prior_wordpress_attempts: u8,
927 prior_wordpress_response_bytes: u64,
928 dispatch_allowed: bool,
929}
930
931impl WordPressAssetFingerprintAllowance {
932 pub(super) fn new(
933 prior_wordpress_attempts: u8,
934 prior_wordpress_response_bytes: u64,
935 dispatch_allowed: bool,
936 ) -> Result<Self, WordPressAssetFingerprintRuntimeError> {
937 if prior_wordpress_attempts > WORDPRESS_TOTAL_REQUEST_LIMIT
938 || prior_wordpress_response_bytes > WORDPRESS_TOTAL_RESPONSE_BYTES
939 {
940 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
941 }
942 Ok(Self {
943 prior_wordpress_attempts,
944 prior_wordpress_response_bytes,
945 dispatch_allowed,
946 })
947 }
948}
949
950#[derive(Clone, Debug, Eq, PartialEq)]
951pub struct WordPressAssetFingerprintResourceReceipt {
952 component: WordPressComponentIdentity,
953 relative_path: String,
954 resource_reference: String,
955 source_page_references: Vec<String>,
956 observed_variant_count: usize,
957 acquisition: WordPressAssetFingerprintAcquisition,
958 outcome: WordPressAssetFingerprintResourceOutcome,
959 request_attempted: bool,
960 interpreted_response_bytes: u64,
961 response_bytes: u64,
962 observation: Option<WordPressObservedAssetFingerprint>,
963 evidence_ids: Vec<EvidenceId>,
964}
965
966impl WordPressAssetFingerprintResourceReceipt {
967 pub const fn component(&self) -> &WordPressComponentIdentity {
968 &self.component
969 }
970
971 pub fn relative_path(&self) -> &str {
972 &self.relative_path
973 }
974
975 pub fn resource_reference(&self) -> &str {
976 &self.resource_reference
977 }
978
979 pub fn source_page_references(&self) -> &[String] {
980 &self.source_page_references
981 }
982
983 pub const fn observed_variant_count(&self) -> usize {
984 self.observed_variant_count
985 }
986
987 pub const fn acquisition(&self) -> WordPressAssetFingerprintAcquisition {
988 self.acquisition
989 }
990
991 pub const fn outcome(&self) -> WordPressAssetFingerprintResourceOutcome {
992 self.outcome
993 }
994
995 pub const fn request_attempted(&self) -> bool {
996 self.request_attempted
997 }
998
999 pub const fn interpreted_response_bytes(&self) -> u64 {
1000 self.interpreted_response_bytes
1001 }
1002
1003 pub const fn response_bytes(&self) -> u64 {
1004 self.response_bytes
1005 }
1006
1007 pub const fn observation(&self) -> Option<&WordPressObservedAssetFingerprint> {
1008 self.observation.as_ref()
1009 }
1010
1011 pub fn evidence_ids(&self) -> &[EvidenceId] {
1012 &self.evidence_ids
1013 }
1014}
1015
1016#[derive(Clone, Debug, Eq, PartialEq)]
1017pub struct WordPressAssetFingerprintExecution {
1018 catalog: Arc<WordPressAssetFingerprintCatalog>,
1019 candidate_count: u64,
1020 selected_resource_count: usize,
1021 omitted_resource_count: usize,
1022 attempted_request_count: u8,
1023 reused_response_count: usize,
1024 fetched_response_count: usize,
1025 response_bytes: u64,
1026 resources: Vec<WordPressAssetFingerprintResourceReceipt>,
1027 component_matches: Vec<WordPressAssetFingerprintComponentMatch>,
1028 stop: WordPressAssetFingerprintStop,
1029}
1030
1031impl WordPressAssetFingerprintExecution {
1032 pub fn catalog(&self) -> &WordPressAssetFingerprintCatalog {
1033 self.catalog.as_ref()
1034 }
1035
1036 pub const fn candidate_count(&self) -> u64 {
1037 self.candidate_count
1038 }
1039
1040 pub const fn selected_resource_count(&self) -> usize {
1041 self.selected_resource_count
1042 }
1043
1044 pub const fn omitted_resource_count(&self) -> usize {
1045 self.omitted_resource_count
1046 }
1047
1048 pub const fn attempted_request_count(&self) -> u8 {
1049 self.attempted_request_count
1050 }
1051
1052 pub const fn reused_response_count(&self) -> usize {
1053 self.reused_response_count
1054 }
1055
1056 pub const fn fetched_response_count(&self) -> usize {
1057 self.fetched_response_count
1058 }
1059
1060 pub const fn response_bytes(&self) -> u64 {
1061 self.response_bytes
1062 }
1063
1064 pub fn resources(&self) -> &[WordPressAssetFingerprintResourceReceipt] {
1065 &self.resources
1066 }
1067
1068 pub fn component_matches(&self) -> &[WordPressAssetFingerprintComponentMatch] {
1069 &self.component_matches
1070 }
1071
1072 pub const fn stop(&self) -> WordPressAssetFingerprintStop {
1073 self.stop
1074 }
1075}
1076
1077pub(super) async fn execute_wordpress_asset_fingerprints(
1078 plan: WordPressAssetFingerprintPlan,
1079 authority: &SharedWebRuntimeAuthority,
1080 root_subject: &EntityId,
1081 allowance: WordPressAssetFingerprintAllowance,
1082 deadline: Option<tokio::time::Instant>,
1083) -> Result<WordPressAssetFingerprintExecution, WordPressAssetFingerprintRuntimeError> {
1084 let WordPressAssetFingerprintPlan {
1085 catalog,
1086 candidate_count,
1087 selected_resource_count,
1088 omitted_resource_count,
1089 candidate_path_counts,
1090 selected,
1091 reused,
1092 ordinary_get_attempts,
1093 ordinary_get_attempt_history_complete,
1094 candidate_identity_limit_exceeded,
1095 source_page_limit_exceeded,
1096 } = plan;
1097 let selected_keys = selected
1098 .iter()
1099 .filter_map(|path| path.variants.first())
1100 .map(|asset| AssetPathKey {
1101 component: asset.component.clone(),
1102 relative_path: asset.relative_path.clone(),
1103 })
1104 .collect::<BTreeSet<_>>();
1105 let selected_targets = selected
1106 .iter()
1107 .flat_map(|path| path.variants.iter())
1108 .map(|asset| {
1109 (
1110 AssetPathKey {
1111 component: asset.component.clone(),
1112 relative_path: asset.relative_path.clone(),
1113 },
1114 asset.target_url.to_string(),
1115 )
1116 })
1117 .collect::<BTreeSet<_>>();
1118 let mut reused_by_path = BTreeMap::<AssetPathKey, Vec<CommittedRepresentation>>::new();
1119 for representation in reused {
1120 let key = AssetPathKey {
1121 component: representation.selected.component.clone(),
1122 relative_path: representation.selected.relative_path.clone(),
1123 };
1124 if selected_targets.contains(&(key.clone(), representation.selected.target_url.to_string()))
1125 {
1126 reused_by_path.entry(key).or_default().push(representation);
1127 }
1128 }
1129 let mut resources = Vec::new();
1130 let mut match_observations =
1131 BTreeMap::<WordPressComponentIdentity, Vec<WordPressObservedAssetFingerprint>>::new();
1132 let selected_path_counts = selected_keys.iter().fold(
1133 BTreeMap::<WordPressComponentIdentity, usize>::new(),
1134 |mut counts, key| {
1135 *counts.entry(key.component.clone()).or_default() += 1;
1136 counts
1137 },
1138 );
1139 let mut completely_interpreted_path_counts =
1140 BTreeMap::<WordPressComponentIdentity, usize>::new();
1141 let mut attempted = 0_u8;
1142 let mut response_bytes = 0_u64;
1143 let mut stop = if candidate_identity_limit_exceeded || source_page_limit_exceeded {
1144 WordPressAssetFingerprintStop::RuntimeLimit
1145 } else if allowance.dispatch_allowed {
1146 WordPressAssetFingerprintStop::Complete
1147 } else {
1148 WordPressAssetFingerprintStop::RuntimeLimit
1149 };
1150
1151 for planned_path in selected {
1152 let Some(selected_asset) = planned_path.variants.first() else {
1153 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
1154 };
1155 let variant_count = planned_path.observed_variant_count;
1156 let path_key = AssetPathKey {
1157 component: selected_asset.component.clone(),
1158 relative_path: selected_asset.relative_path.clone(),
1159 };
1160 if let Some(reused_representations) = reused_by_path.remove(&path_key) {
1161 let all_selected_variants_interpreted = selected_variants_completely_interpreted(
1162 &planned_path.variants,
1163 &reused_representations,
1164 variant_count,
1165 );
1166 let (receipt_target_url, observations, evidence_ids) =
1167 summarize_reused_representations(reused_representations);
1168 let receipt_asset = planned_path
1169 .variants
1170 .iter()
1171 .find(|variant| variant.target_url == receipt_target_url)
1172 .unwrap_or(selected_asset);
1173 match_observations
1174 .entry(receipt_asset.component.clone())
1175 .or_default()
1176 .extend(observations.iter().cloned());
1177 if all_selected_variants_interpreted {
1178 *completely_interpreted_path_counts
1179 .entry(receipt_asset.component.clone())
1180 .or_default() += 1;
1181 }
1182 let (outcome, observation, evidence_ids) = if observations.len() == 1 {
1183 (
1184 WordPressAssetFingerprintResourceOutcome::Observed,
1185 observations.into_iter().next(),
1186 evidence_ids,
1187 )
1188 } else {
1189 (
1190 WordPressAssetFingerprintResourceOutcome::ConflictingRepresentations,
1191 None,
1192 evidence_ids,
1193 )
1194 };
1195 resources.push(resource_receipt(
1196 receipt_asset,
1197 variant_count,
1198 WordPressAssetFingerprintAcquisition::Reused,
1199 outcome,
1200 false,
1201 0,
1202 observation,
1203 evidence_ids,
1204 ));
1205 continue;
1206 }
1207 let prior_attempt_for_path = !ordinary_get_attempt_history_complete
1208 || planned_path
1209 .variants
1210 .iter()
1211 .any(|variant| ordinary_get_attempts.contains(variant.target_url.as_str()));
1212 if prior_attempt_for_path {
1213 resources.push(resource_receipt(
1214 selected_asset,
1215 variant_count,
1216 WordPressAssetFingerprintAcquisition::NotAcquired,
1217 WordPressAssetFingerprintResourceOutcome::OrdinaryResponseIneligible,
1218 false,
1219 0,
1220 None,
1221 Vec::new(),
1222 ));
1223 continue;
1224 }
1225 if stop != WordPressAssetFingerprintStop::Complete {
1226 resources.push(resource_receipt(
1227 selected_asset,
1228 variant_count,
1229 WordPressAssetFingerprintAcquisition::NotAcquired,
1230 stop_resource_outcome(stop),
1231 false,
1232 0,
1233 None,
1234 Vec::new(),
1235 ));
1236 continue;
1237 }
1238 if attempted >= MAX_WORDPRESS_ASSET_FINGERPRINT_REQUESTS
1239 || allowance.prior_wordpress_attempts.saturating_add(attempted)
1240 >= WORDPRESS_TOTAL_REQUEST_LIMIT
1241 {
1242 stop = WordPressAssetFingerprintStop::RequestLimit;
1243 resources.push(resource_receipt(
1244 selected_asset,
1245 variant_count,
1246 WordPressAssetFingerprintAcquisition::NotAcquired,
1247 WordPressAssetFingerprintResourceOutcome::BudgetExhausted,
1248 false,
1249 0,
1250 None,
1251 Vec::new(),
1252 ));
1253 continue;
1254 }
1255 if authority.cancellation().is_cancelled() {
1256 stop = WordPressAssetFingerprintStop::Cancelled;
1257 resources.push(resource_receipt(
1258 selected_asset,
1259 variant_count,
1260 WordPressAssetFingerprintAcquisition::NotAcquired,
1261 WordPressAssetFingerprintResourceOutcome::Cancelled,
1262 false,
1263 0,
1264 None,
1265 Vec::new(),
1266 ));
1267 continue;
1268 }
1269 if deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
1270 stop = WordPressAssetFingerprintStop::DeadlineExceeded;
1271 resources.push(resource_receipt(
1272 selected_asset,
1273 variant_count,
1274 WordPressAssetFingerprintAcquisition::NotAcquired,
1275 WordPressAssetFingerprintResourceOutcome::DeadlineExceeded,
1276 false,
1277 0,
1278 None,
1279 Vec::new(),
1280 ));
1281 continue;
1282 }
1283 let total_response_bytes = allowance
1284 .prior_wordpress_response_bytes
1285 .saturating_add(response_bytes);
1286 if total_response_bytes >= WORDPRESS_TOTAL_RESPONSE_BYTES {
1287 stop = WordPressAssetFingerprintStop::ResponseLimit;
1288 resources.push(resource_receipt(
1289 selected_asset,
1290 variant_count,
1291 WordPressAssetFingerprintAcquisition::NotAcquired,
1292 WordPressAssetFingerprintResourceOutcome::BudgetExhausted,
1293 false,
1294 0,
1295 None,
1296 Vec::new(),
1297 ));
1298 continue;
1299 }
1300 let response_limit = WORDPRESS_TOTAL_RESPONSE_BYTES
1301 .saturating_sub(total_response_bytes)
1302 .min(MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES.saturating_add(1));
1303 let descriptor = selected_asset.request_descriptor(root_subject, authority.knowledge())?;
1304 let before = authority.request_accounting().snapshot();
1305 let request = authority
1306 .requests()
1307 .collect_anonymous_wordpress_asset_get_for_runtime(
1308 WORDPRESS_ASSET_FINGERPRINT_ACTION_ID,
1309 DecisionExecutionStage::Passive,
1310 Some(DecisionActionOrigin::Planned),
1311 DecisionExecutionLimits::new().with_max_response_body_bytes(response_limit),
1312 &descriptor,
1313 authority.knowledge(),
1314 );
1315 let collected =
1316 await_asset_request(request, authority.cancellation_token(), deadline).await;
1317 let after = authority.request_accounting().snapshot();
1318 let request_delta = after
1319 .total_requests()
1320 .saturating_sub(before.total_requests());
1321 let response_delta = after
1322 .response_bytes()
1323 .saturating_sub(before.response_bytes());
1324 if request_delta > 1 {
1325 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
1326 }
1327 attempted = attempted.saturating_add(u8::try_from(request_delta).unwrap_or(u8::MAX));
1328 response_bytes = response_bytes.saturating_add(response_delta);
1329 let request_attempted = request_delta == 1;
1330 let (outcome, observation, evidence_ids, next_stop) = match collected {
1331 BoundedAssetRequest::Cancelled => (
1332 WordPressAssetFingerprintResourceOutcome::Cancelled,
1333 None,
1334 Vec::new(),
1335 Some(WordPressAssetFingerprintStop::Cancelled),
1336 ),
1337 BoundedAssetRequest::DeadlineExceeded => (
1338 WordPressAssetFingerprintResourceOutcome::DeadlineExceeded,
1339 None,
1340 Vec::new(),
1341 Some(WordPressAssetFingerprintStop::DeadlineExceeded),
1342 ),
1343 BoundedAssetRequest::Completed(result) => match *result {
1344 Err(HttpRequestBrokerError::RuntimeLimit(_)) => (
1345 WordPressAssetFingerprintResourceOutcome::BudgetExhausted,
1346 None,
1347 Vec::new(),
1348 Some(WordPressAssetFingerprintStop::RuntimeLimit),
1349 ),
1350 Err(HttpRequestBrokerError::Http(_)) => (
1351 WordPressAssetFingerprintResourceOutcome::RequestFailed,
1352 None,
1353 Vec::new(),
1354 None,
1355 ),
1356 Ok(response) => {
1357 let outcome = classify_asset_response(selected_asset, &response);
1358 let observation =
1359 if outcome == WordPressAssetFingerprintResourceOutcome::Observed {
1360 let body = response
1361 .complete_identity_content_body()
1362 .ok_or(WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1363 let fingerprint = WordPressObservedAssetFingerprint::new(
1364 selected_asset.component.clone(),
1365 selected_asset.relative_path.clone(),
1366 u64::try_from(body.len()).unwrap_or(u64::MAX),
1367 Sha256::digest(body).into(),
1368 )?;
1369 Some(fingerprint)
1370 } else {
1371 None
1372 };
1373 let evidence_ids = if let Some(observation) = observation.as_ref() {
1374 let evidence = fetched_representation_evidence(
1375 root_subject,
1376 selected_asset,
1377 observation,
1378 authority.requests().policy().reliability(),
1379 )?;
1380 let id = evidence.id().clone();
1381 authority
1382 .knowledge()
1383 .insert_evidence_batch(vec![evidence])
1384 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceCommit)?;
1385 vec![id]
1386 } else {
1387 Vec::new()
1388 };
1389 let next_stop = (outcome
1390 == WordPressAssetFingerprintResourceOutcome::RateLimited)
1391 .then_some(WordPressAssetFingerprintStop::RateLimited);
1392 (outcome, observation, evidence_ids, next_stop)
1393 },
1394 },
1395 };
1396 if let Some(next_stop) = next_stop {
1397 stop = next_stop;
1398 }
1399 if allowance
1400 .prior_wordpress_response_bytes
1401 .saturating_add(response_bytes)
1402 > WORDPRESS_TOTAL_RESPONSE_BYTES
1403 {
1404 stop = WordPressAssetFingerprintStop::ResponseLimit;
1405 }
1406 if let Some(observation) = observation.as_ref() {
1407 match_observations
1408 .entry(selected_asset.component.clone())
1409 .or_default()
1410 .push(observation.clone());
1411 if variant_count == 1 {
1412 *completely_interpreted_path_counts
1413 .entry(selected_asset.component.clone())
1414 .or_default() += 1;
1415 }
1416 }
1417 resources.push(resource_receipt(
1418 selected_asset,
1419 variant_count,
1420 if request_attempted {
1421 WordPressAssetFingerprintAcquisition::Fetched
1422 } else {
1423 WordPressAssetFingerprintAcquisition::NotAcquired
1424 },
1425 outcome,
1426 request_attempted,
1427 response_delta,
1428 observation,
1429 evidence_ids,
1430 ));
1431 }
1432
1433 resources.sort_by(|left, right| {
1434 left.component
1435 .cmp(&right.component)
1436 .then_with(|| left.relative_path.cmp(&right.relative_path))
1437 .then_with(|| left.resource_reference.cmp(&right.resource_reference))
1438 });
1439 let selected_components = selected_keys
1440 .iter()
1441 .map(|key| key.component.clone())
1442 .collect::<BTreeSet<_>>();
1443 let mut component_matches = Vec::with_capacity(selected_components.len());
1444 for component in selected_components {
1445 let matched = match_wordpress_asset_fingerprint_component(
1446 &catalog,
1447 &component,
1448 match_observations
1449 .get(&component)
1450 .map_or(&[], Vec::as_slice),
1451 )?;
1452 let selected_path_count = selected_path_counts.get(&component).copied().unwrap_or(0);
1453 let candidate_path_count = candidate_path_counts.get(&component).copied().unwrap_or(0);
1454 let completely_interpreted_path_count = completely_interpreted_path_counts
1455 .get(&component)
1456 .copied()
1457 .unwrap_or(0);
1458 if candidate_path_count < selected_path_count
1459 || selected_path_count < completely_interpreted_path_count
1460 {
1461 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
1462 }
1463 component_matches.push(matched.with_collection_coverage(
1464 candidate_path_count,
1465 selected_path_count,
1466 completely_interpreted_path_count,
1467 ));
1468 }
1469 let reused_response_count = resources
1470 .iter()
1471 .filter(|resource| resource.acquisition == WordPressAssetFingerprintAcquisition::Reused)
1472 .count();
1473 let fetched_response_count = resources
1474 .iter()
1475 .filter(|resource| {
1476 resource.acquisition == WordPressAssetFingerprintAcquisition::Fetched
1477 && resource.outcome == WordPressAssetFingerprintResourceOutcome::Observed
1478 })
1479 .count();
1480 Ok(WordPressAssetFingerprintExecution {
1481 catalog,
1482 candidate_count,
1483 selected_resource_count,
1484 omitted_resource_count,
1485 attempted_request_count: attempted,
1486 reused_response_count,
1487 fetched_response_count,
1488 response_bytes,
1489 resources,
1490 component_matches,
1491 stop,
1492 })
1493}
1494
1495enum BoundedAssetRequest {
1496 Completed(Box<Result<crate::http_evidence::CollectedHttpResponse, HttpRequestBrokerError>>),
1497 Cancelled,
1498 DeadlineExceeded,
1499}
1500
1501async fn await_asset_request(
1502 request: impl std::future::Future<
1503 Output = Result<crate::http_evidence::CollectedHttpResponse, HttpRequestBrokerError>,
1504 >,
1505 cancellation: CancellationToken,
1506 deadline: Option<tokio::time::Instant>,
1507) -> BoundedAssetRequest {
1508 match deadline {
1509 Some(deadline) => {
1510 tokio::select! {
1511 _ = cancellation.cancelled() => BoundedAssetRequest::Cancelled,
1512 result = tokio::time::timeout_at(deadline, request) => match result {
1513 Ok(result) => BoundedAssetRequest::Completed(Box::new(result)),
1514 Err(_) => BoundedAssetRequest::DeadlineExceeded,
1515 },
1516 }
1517 },
1518 None => {
1519 tokio::select! {
1520 _ = cancellation.cancelled() => BoundedAssetRequest::Cancelled,
1521 result = request => BoundedAssetRequest::Completed(Box::new(result)),
1522 }
1523 },
1524 }
1525}
1526
1527fn classify_asset_response(
1528 selected: &WordPressSelectedObservedAsset,
1529 response: &crate::http_evidence::CollectedHttpResponse,
1530) -> WordPressAssetFingerprintResourceOutcome {
1531 match response.status() {
1532 429 => return WordPressAssetFingerprintResourceOutcome::RateLimited,
1533 401 | 403 => return WordPressAssetFingerprintResourceOutcome::Unauthorized,
1534 404 => return WordPressAssetFingerprintResourceOutcome::NotFound,
1535 300..=399 => return WordPressAssetFingerprintResourceOutcome::RedirectObserved,
1536 206 => return WordPressAssetFingerprintResourceOutcome::PartialOrIncomplete,
1537 200 => {},
1538 _ => return WordPressAssetFingerprintResourceOutcome::RequestFailed,
1539 }
1540 if response.final_url() != selected.target_url() {
1541 return WordPressAssetFingerprintResourceOutcome::RedirectObserved;
1542 }
1543 if response.body_truncated() || !response.body_complete() {
1544 return WordPressAssetFingerprintResourceOutcome::PartialOrIncomplete;
1545 }
1546 if !response.content_encoding_is_absent() {
1547 return WordPressAssetFingerprintResourceOutcome::UnsupportedContentEncoding;
1548 }
1549 if !response.content_length_is_consistent() {
1550 return WordPressAssetFingerprintResourceOutcome::InconsistentContentLength;
1551 }
1552 if !asset_media_type_is_supported(
1553 selected.relative_path(),
1554 response.normalized_media_type().as_deref(),
1555 ) {
1556 return WordPressAssetFingerprintResourceOutcome::UnsupportedMediaType;
1557 }
1558 let Some(body) = response.complete_identity_content_body() else {
1559 return WordPressAssetFingerprintResourceOutcome::PartialOrIncomplete;
1560 };
1561 if u64::try_from(body.len()).unwrap_or(u64::MAX) > MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES
1562 || looks_like_html(body)
1563 {
1564 return WordPressAssetFingerprintResourceOutcome::UnsupportedMediaType;
1565 }
1566 WordPressAssetFingerprintResourceOutcome::Observed
1567}
1568
1569fn asset_media_type_is_supported(relative_path: &str, media_type: Option<&str>) -> bool {
1570 let Some(media_type) = media_type else {
1571 return false;
1572 };
1573 if relative_path.ends_with(".css") {
1574 media_type == "text/css"
1575 } else if relative_path.ends_with(".js") {
1576 matches!(
1577 media_type,
1578 "text/javascript"
1579 | "application/javascript"
1580 | "application/ecmascript"
1581 | "text/ecmascript"
1582 )
1583 } else {
1584 false
1585 }
1586}
1587
1588fn looks_like_html(body: &[u8]) -> bool {
1589 let body = body.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(body);
1590 let body = body
1591 .iter()
1592 .position(|byte| !byte.is_ascii_whitespace())
1593 .map_or(&[][..], |start| &body[start..]);
1594 [b"<!doctype html".as_slice(), b"<html".as_slice()]
1595 .iter()
1596 .any(|prefix| {
1597 body.get(..prefix.len())
1598 .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
1599 })
1600}
1601
1602#[allow(clippy::too_many_arguments)]
1603fn resource_receipt(
1604 selected: &WordPressSelectedObservedAsset,
1605 observed_variant_count: usize,
1606 acquisition: WordPressAssetFingerprintAcquisition,
1607 outcome: WordPressAssetFingerprintResourceOutcome,
1608 request_attempted: bool,
1609 response_bytes: u64,
1610 observation: Option<WordPressObservedAssetFingerprint>,
1611 evidence_ids: Vec<EvidenceId>,
1612) -> WordPressAssetFingerprintResourceReceipt {
1613 let interpreted_response_bytes = observation
1614 .as_ref()
1615 .map_or(0, WordPressObservedAssetFingerprint::byte_length);
1616 WordPressAssetFingerprintResourceReceipt {
1617 component: selected.component.clone(),
1618 relative_path: selected.relative_path.clone(),
1619 resource_reference: selected.resource_reference.clone(),
1620 source_page_references: selected.source_page_references.clone(),
1621 observed_variant_count,
1622 acquisition,
1623 outcome,
1624 request_attempted,
1625 interpreted_response_bytes,
1626 response_bytes,
1627 observation,
1628 evidence_ids,
1629 }
1630}
1631
1632const fn stop_resource_outcome(
1633 stop: WordPressAssetFingerprintStop,
1634) -> WordPressAssetFingerprintResourceOutcome {
1635 match stop {
1636 WordPressAssetFingerprintStop::Cancelled => {
1637 WordPressAssetFingerprintResourceOutcome::Cancelled
1638 },
1639 WordPressAssetFingerprintStop::DeadlineExceeded => {
1640 WordPressAssetFingerprintResourceOutcome::DeadlineExceeded
1641 },
1642 WordPressAssetFingerprintStop::RateLimited => {
1643 WordPressAssetFingerprintResourceOutcome::RateLimited
1644 },
1645 WordPressAssetFingerprintStop::Complete
1646 | WordPressAssetFingerprintStop::RequestLimit
1647 | WordPressAssetFingerprintStop::ResponseLimit
1648 | WordPressAssetFingerprintStop::RuntimeLimit => {
1649 WordPressAssetFingerprintResourceOutcome::BudgetExhausted
1650 },
1651 }
1652}
1653
1654fn selected_assets(state: &CollectedFingerprintState) -> Vec<WordPressSelectedObservedAsset> {
1655 let selected_components = state
1656 .candidates
1657 .keys()
1658 .map(|key| key.component.clone())
1659 .collect::<BTreeSet<_>>()
1660 .into_iter()
1661 .take(MAX_WORDPRESS_ASSET_FINGERPRINT_COMPONENTS)
1662 .collect::<BTreeSet<_>>();
1663 let mut paths_per_component = BTreeMap::<WordPressComponentIdentity, usize>::new();
1664 let mut selected = Vec::new();
1665 for (key, variants) in &state.candidates {
1666 if !selected_components.contains(&key.component) {
1667 continue;
1668 }
1669 let retained_paths = paths_per_component
1670 .entry(key.component.clone())
1671 .or_default();
1672 if *retained_paths == MAX_WORDPRESS_ASSET_FINGERPRINT_PATHS_PER_COMPONENT {
1673 continue;
1674 }
1675 *retained_paths += 1;
1676 for committed in variants.values() {
1677 selected.push(WordPressSelectedObservedAsset {
1678 application_url: committed.candidate.application_url.clone(),
1679 role_base_url: committed.candidate.role_base_url.clone(),
1680 target_url: committed.candidate.target_url.clone(),
1681 component: committed.candidate.component.clone(),
1682 association: committed.candidate.association,
1683 relative_path: committed.candidate.relative_path.clone(),
1684 resource_reference: asset_path_reference(
1685 &committed.candidate.application_url,
1686 &committed.candidate.component,
1687 &committed.candidate.relative_path,
1688 ),
1689 source_page_references: committed.source_page_references.iter().cloned().collect(),
1690 source_evidence_ids: committed.root_evidence_ids.iter().cloned().collect(),
1691 });
1692 }
1693 }
1694 selected
1695}
1696
1697fn asset_path_reference(
1698 application_url: &Url,
1699 component: &WordPressComponentIdentity,
1700 relative_path: &str,
1701) -> String {
1702 let mut digest = Sha256::new();
1703 digest.update(b"termivar.wordpress-asset-resource-path/v1\0");
1704 for value in [
1705 application_url.as_str(),
1706 component_kind_name(component.kind()),
1707 component.slug(),
1708 relative_path,
1709 ] {
1710 digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
1711 digest.update(value.as_bytes());
1712 }
1713 format!("sha256:{:x}", digest.finalize())
1714}
1715
1716fn selected_variants_completely_interpreted(
1717 selected: &[WordPressSelectedObservedAsset],
1718 representations: &[CommittedRepresentation],
1719 observed_variant_count: usize,
1720) -> bool {
1721 let represented_urls = representations
1722 .iter()
1723 .map(|representation| representation.selected.target_url.as_str())
1724 .collect::<BTreeSet<_>>();
1725 observed_variant_count == 1
1726 && selected.len() == 1
1727 && selected
1728 .iter()
1729 .all(|variant| represented_urls.contains(variant.target_url.as_str()))
1730}
1731
1732fn summarize_reused_representations(
1733 mut representations: Vec<CommittedRepresentation>,
1734) -> (Url, Vec<WordPressObservedAssetFingerprint>, Vec<EvidenceId>) {
1735 representations.sort_by(|left, right| {
1736 left.selected
1737 .target_url
1738 .as_str()
1739 .cmp(right.selected.target_url.as_str())
1740 .then_with(|| left.root_evidence_id.cmp(&right.root_evidence_id))
1741 });
1742 let receipt_target_url = representations[0].selected.target_url.clone();
1743 let receipt_evidence_id = representations[0].root_evidence_id.clone();
1744 let mut distinct = BTreeMap::<(u64, [u8; 32]), WordPressObservedAssetFingerprint>::new();
1745 let mut evidence_ids = BTreeSet::new();
1746 for representation in representations {
1747 let key = (
1748 representation.observation.byte_length(),
1749 *representation.observation.sha256(),
1750 );
1751 distinct.entry(key).or_insert(representation.observation);
1752 evidence_ids.insert(representation.root_evidence_id);
1753 }
1754 let observations = distinct.into_values().collect::<Vec<_>>();
1755 let evidence_ids = if observations.len() == 1 {
1756 vec![receipt_evidence_id]
1757 } else {
1758 evidence_ids.into_iter().collect()
1759 };
1760 (receipt_target_url, observations, evidence_ids)
1761}
1762
1763fn target_variant_fingerprint(candidate: &WordPressObservedAssetCandidate) -> [u8; 32] {
1764 let mut digest = Sha256::new();
1765 digest.update(b"termivar.wordpress-observed-asset-target-variant/v1\0");
1766 for value in [
1767 component_kind_name(candidate.component.kind()),
1768 candidate.component.slug(),
1769 candidate.relative_path.as_str(),
1770 candidate.target_url.as_str(),
1771 ] {
1772 digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
1773 digest.update(value.as_bytes());
1774 }
1775 digest.finalize().into()
1776}
1777
1778fn candidate_fingerprint(candidate: &WordPressObservedAssetCandidate) -> [u8; 32] {
1779 let mut digest = Sha256::new();
1780 digest.update(b"termivar.wordpress-observed-asset-candidate/v1\0");
1781 for value in [
1782 candidate.application_url.as_str(),
1783 candidate.role_base_url.as_str(),
1784 candidate.document_url.as_str(),
1785 candidate.target_url.as_str(),
1786 component_kind_name(candidate.component.kind()),
1787 candidate.component.slug(),
1788 candidate.association.as_str(),
1789 candidate.relative_path.as_str(),
1790 candidate.source_page_reference.as_str(),
1791 ] {
1792 digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
1793 digest.update(value.as_bytes());
1794 }
1795 digest.finalize().into()
1796}
1797
1798fn candidate_identity_capacity_allows(
1799 existing: &BTreeSet<[u8; 32]>,
1800 candidates: &[WordPressObservedAssetCandidate],
1801) -> bool {
1802 let new_identities = candidates
1803 .iter()
1804 .map(candidate_fingerprint)
1805 .filter(|fingerprint| !existing.contains(fingerprint))
1806 .collect::<BTreeSet<_>>();
1807 existing
1808 .len()
1809 .checked_add(new_identities.len())
1810 .is_some_and(|count| count <= MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES)
1811}
1812
1813fn source_page_capacity_allows(
1814 existing: &BTreeMap<AssetPathKey, BTreeMap<String, CommittedCandidate>>,
1815 candidates: &[WordPressObservedAssetCandidate],
1816) -> bool {
1817 let mut pages_by_resource = existing
1818 .iter()
1819 .map(|(key, variants)| {
1820 let pages = variants
1821 .values()
1822 .flat_map(|committed| committed.source_page_references.iter().cloned())
1823 .collect::<BTreeSet<_>>();
1824 (key.clone(), pages)
1825 })
1826 .collect::<BTreeMap<_, _>>();
1827 for candidate in candidates {
1828 let pages = pages_by_resource
1829 .entry(AssetPathKey::of(candidate))
1830 .or_default();
1831 pages.insert(candidate.source_page_reference.clone());
1832 if pages.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_SOURCE_PAGES {
1833 return false;
1834 }
1835 }
1836 true
1837}
1838
1839fn component_kind_name(kind: WordPressComponentKind) -> &'static str {
1840 match kind {
1841 WordPressComponentKind::Core => "core",
1842 WordPressComponentKind::Plugin => "plugin",
1843 WordPressComponentKind::Theme => "theme",
1844 }
1845}
1846
1847fn asset_request_source(
1848 association: WordPressDiscoveryAssociation,
1849) -> Result<WordPressMetadataRequestSource, WordPressAssetFingerprintRuntimeError> {
1850 match association {
1851 WordPressDiscoveryAssociation::ObservedConventional => {
1852 Ok(WordPressMetadataRequestSource::ObservedConventional)
1853 },
1854 WordPressDiscoveryAssociation::ExplicitOperator => {
1855 Ok(WordPressMetadataRequestSource::ExplicitOperator)
1856 },
1857 _ => Err(WordPressAssetFingerprintRuntimeError::InvalidCandidate),
1858 }
1859}
1860
1861fn candidate_binding_evidence(
1862 root_subject: &EntityId,
1863 candidate: &WordPressObservedAssetCandidate,
1864) -> Result<Evidence, WordPressAssetFingerprintRuntimeError> {
1865 let binding_value = wordpress_asset_request_binding_value(
1866 &candidate.application_url,
1867 &candidate.role_base_url,
1868 &candidate.target_url,
1869 &candidate.component,
1870 asset_request_source(candidate.association)?,
1871 &candidate.relative_path,
1872 &candidate.source_page_reference,
1873 );
1874 let source = EvidenceSource::new(
1875 WORDPRESS_ASSET_NOMINATION_COMPONENT,
1876 WORDPRESS_ASSET_NOMINATION_METHOD,
1877 )
1878 .and_then(|source| source.with_correlation_id(candidate.source_page_reference.clone()))
1879 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1880 let predicate = KnowledgePredicate::new(
1881 WORDPRESS_ASSET_NOMINATION_NAMESPACE,
1882 WORDPRESS_ASSET_NOMINATION_PREDICATE,
1883 )
1884 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1885 let reliability = ConfidenceScore::from_percent(70)
1886 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1887 Ok(Evidence::new(
1893 root_subject.clone(),
1894 EvidenceKind::Content,
1895 predicate,
1896 EvidenceValue::Text(binding_value),
1897 source,
1898 reliability,
1899 ))
1900}
1901
1902fn representation_evidence_value(
1903 resource_reference: &str,
1904 byte_length: u64,
1905 sha256: &[u8; 32],
1906) -> String {
1907 format!(
1908 "{resource_reference}:bytes:{byte_length}:sha256:{}",
1909 hex_sha256(sha256)
1910 )
1911}
1912
1913fn hex_sha256(digest: &[u8; 32]) -> String {
1914 use std::fmt::Write as _;
1915
1916 let mut value = String::with_capacity(64);
1917 for byte in digest {
1918 let _ = write!(value, "{byte:02x}");
1919 }
1920 value
1921}
1922
1923fn root_representation_binding_evidence(
1924 root_subject: &EntityId,
1925 selected: &WordPressSelectedObservedAsset,
1926 observation: &WordPressObservedAssetFingerprint,
1927 endpoint_evidence_id: &EvidenceId,
1928 method: &'static str,
1929) -> Result<Evidence, WordPressAssetFingerprintRuntimeError> {
1930 let source = EvidenceSource::new("wordpress.asset-fingerprint", method)
1931 .and_then(|source| source.with_correlation_id(selected.resource_reference.clone()))
1932 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1933 let predicate =
1934 KnowledgePredicate::new(ASSET_EVIDENCE_NAMESPACE, ASSET_REPRESENTATION_PREDICATE)
1935 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1936 let reliability = ConfidenceScore::from_percent(70)
1937 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1938 let mut binding_digest = Sha256::new();
1944 binding_digest.update(endpoint_evidence_id.to_string().as_bytes());
1945 binding_digest.update(observation.byte_length().to_be_bytes());
1946 binding_digest.update(observation.sha256());
1947 Ok(Evidence::new(
1948 root_subject.clone(),
1949 EvidenceKind::Content,
1950 predicate,
1951 EvidenceValue::Text(format!(
1952 "{}:source-binding:sha256:{:x}",
1953 representation_evidence_value(
1954 selected.resource_reference(),
1955 observation.byte_length(),
1956 observation.sha256(),
1957 ),
1958 binding_digest.finalize()
1959 )),
1960 source,
1961 reliability,
1962 ))
1963}
1964
1965fn fetched_representation_evidence(
1966 root_subject: &EntityId,
1967 selected: &WordPressSelectedObservedAsset,
1968 observation: &WordPressObservedAssetFingerprint,
1969 reliability: ConfidenceScore,
1970) -> Result<Evidence, WordPressAssetFingerprintRuntimeError> {
1971 if selected.source_evidence_ids.is_empty()
1972 || selected.source_evidence_ids.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_SOURCE_EVIDENCE
1973 {
1974 return Err(WordPressAssetFingerprintRuntimeError::EvidenceModel);
1975 }
1976 let source = EvidenceSource::new(
1977 "wordpress.asset-fingerprint",
1978 "fingerprint-owned-identity-get",
1979 )
1980 .and_then(|source| source.with_correlation_id(selected.resource_reference.clone()))
1981 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1982 let predicate =
1983 KnowledgePredicate::new(ASSET_EVIDENCE_NAMESPACE, ASSET_REPRESENTATION_PREDICATE)
1984 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1985 let derivation = EvidenceDerivation::new(
1986 selected.source_evidence_ids.iter().cloned(),
1987 DerivationAlgorithm::new(ASSET_FETCHED_ROOT_DERIVATION, 1)
1988 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?,
1989 )
1990 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?;
1991 Ok(Evidence::new(
1992 root_subject.clone(),
1993 EvidenceKind::Content,
1994 predicate,
1995 EvidenceValue::Text(representation_evidence_value(
1996 selected.resource_reference(),
1997 observation.byte_length(),
1998 observation.sha256(),
1999 )),
2000 source,
2001 reliability.min(
2002 ConfidenceScore::from_percent(70)
2003 .map_err(|_| WordPressAssetFingerprintRuntimeError::EvidenceModel)?,
2004 ),
2005 )
2006 .derived_from(derivation))
2007}
2008
2009fn complete_asset_parent_evidence_ids(
2010 observation: &CompleteHttpResponseObservation<'_>,
2011) -> Result<Vec<EvidenceId>, HttpEvidenceError> {
2012 [
2013 (observation.request_method_evidence_id(), "request method"),
2014 (observation.request_url_evidence_id(), "request URL"),
2015 (observation.response_status_evidence_id(), "response status"),
2016 (
2017 observation.response_final_url_evidence_id(),
2018 "response final URL",
2019 ),
2020 (
2021 observation.response_media_type_evidence_id(),
2022 "response media type",
2023 ),
2024 (
2025 observation.response_body_bytes_evidence_id(),
2026 "response body byte count",
2027 ),
2028 (
2029 observation.response_body_truncated_evidence_id(),
2030 "response truncation",
2031 ),
2032 (
2033 observation.response_body_digest_evidence_id(),
2034 "response body digest",
2035 ),
2036 ]
2037 .into_iter()
2038 .map(|(id, invariant)| {
2039 id.cloned()
2040 .ok_or(HttpEvidenceError::AssessmentObserverInvariant { invariant })
2041 })
2042 .collect()
2043}
2044
2045fn committed_html_response_is_valid(
2046 knowledge: &KnowledgeBase,
2047 evidence_ids: &[EvidenceId],
2048 expected_subject: &EntityId,
2049 expected_url: &Url,
2050) -> bool {
2051 committed_response_base_is_valid(
2052 knowledge,
2053 evidence_ids,
2054 expected_subject,
2055 expected_url,
2056 |media_type| media_type == "text/html",
2057 None,
2058 None,
2059 )
2060}
2061
2062fn committed_asset_response_is_valid(
2063 knowledge: &KnowledgeBase,
2064 pending: &PendingRepresentation,
2065 expected_subject: &EntityId,
2066 expected_url: &Url,
2067) -> bool {
2068 if !committed_response_base_is_valid(
2069 knowledge,
2070 &pending.parent_evidence_ids,
2071 expected_subject,
2072 expected_url,
2073 |media_type| media_type == pending.media_type,
2074 Some(pending.observation.byte_length()),
2075 Some(pending.observation.sha256()),
2076 ) {
2077 return false;
2078 }
2079 knowledge
2080 .inspect_evidence(&pending.endpoint_evidence_id, |evidence| {
2081 evidence.subject() == expected_subject
2082 && evidence.predicate()
2083 == &KnowledgePredicate::new(
2084 ASSET_EVIDENCE_NAMESPACE,
2085 ASSET_REPRESENTATION_PREDICATE,
2086 )
2087 .expect("fixed fingerprint predicate")
2088 && evidence.value() == &EvidenceValue::Text(pending.evidence_value.clone())
2089 && evidence.origin().derivation().is_some_and(|derivation| {
2090 derivation.algorithm().name() == ASSET_RESPONSE_DERIVATION
2091 && derivation.algorithm().version() == 1
2092 && derivation.parents()
2093 == pending
2094 .parent_evidence_ids
2095 .iter()
2096 .cloned()
2097 .collect::<BTreeSet<_>>()
2098 .into_iter()
2099 .collect::<Vec<_>>()
2100 })
2101 })
2102 .unwrap_or(false)
2103}
2104
2105#[allow(clippy::too_many_arguments)]
2106fn committed_response_base_is_valid(
2107 knowledge: &KnowledgeBase,
2108 evidence_ids: &[EvidenceId],
2109 expected_subject: &EntityId,
2110 expected_url: &Url,
2111 media_type_is_valid: impl Fn(&str) -> bool,
2112 expected_body_bytes: Option<u64>,
2113 expected_sha256: Option<&[u8; 32]>,
2114) -> bool {
2115 if evidence_ids.len() != 8
2116 || evidence_ids.iter().collect::<BTreeSet<_>>().len() != evidence_ids.len()
2117 || evidence_ids.iter().any(|id| {
2118 knowledge
2119 .inspect_evidence(id, |evidence| evidence.subject() != expected_subject)
2120 .unwrap_or(true)
2121 })
2122 {
2123 return false;
2124 }
2125 let unique_value = |predicate: PredicateDescriptor| {
2126 let predicate = predicate.into_knowledge();
2127 let mut values = evidence_ids.iter().filter_map(|id| {
2128 knowledge.inspect_evidence(id, |evidence| {
2129 (evidence.predicate() == &predicate).then(|| evidence.value().clone())
2130 })?
2131 });
2132 let value = values.next();
2133 (values.next().is_none()).then_some(value).flatten()
2134 };
2135 if unique_value(HttpEvidencePredicate::REQUEST_METHOD)
2136 != Some(EvidenceValue::Text("GET".to_owned()))
2137 || unique_value(HttpEvidencePredicate::REQUEST_URL)
2138 != Some(EvidenceValue::Text(expected_url.to_string()))
2139 || unique_value(HttpEvidencePredicate::RESPONSE_STATUS)
2140 != Some(EvidenceValue::Unsigned(200))
2141 || unique_value(HttpEvidencePredicate::RESPONSE_FINAL_URL)
2142 != Some(EvidenceValue::Text(expected_url.to_string()))
2143 || unique_value(HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED)
2144 != Some(EvidenceValue::Boolean(false))
2145 {
2146 return false;
2147 }
2148 let media_type = unique_value(HttpEvidencePredicate::RESPONSE_MEDIA_TYPE);
2149 if !matches!(media_type, Some(EvidenceValue::Text(ref value)) if media_type_is_valid(value)) {
2150 return false;
2151 }
2152 let body_bytes = unique_value(HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED);
2153 let Some(EvidenceValue::Unsigned(body_bytes)) = body_bytes else {
2154 return false;
2155 };
2156 if expected_body_bytes.is_some_and(|expected| expected != body_bytes) {
2157 return false;
2158 }
2159 let digest_predicate = HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge();
2160 let digests = evidence_ids
2161 .iter()
2162 .filter_map(|id| {
2163 knowledge.inspect_evidence(id, |evidence| {
2164 (evidence.predicate() == &digest_predicate).then(|| evidence.value().clone())
2165 })?
2166 })
2167 .collect::<Vec<_>>();
2168 #[cfg(feature = "secret-exposure-review")]
2169 let lineages = {
2170 let request_correlation = evidence_ids.iter().find_map(|id| {
2171 knowledge.inspect_evidence(id, |evidence| {
2172 (evidence.predicate() == &HttpEvidencePredicate::REQUEST_METHOD.into_knowledge())
2173 .then(|| evidence.source().correlation_id().map(str::to_owned))
2174 .flatten()
2175 })?
2176 });
2177 let Some(request_correlation) = request_correlation else {
2178 return false;
2179 };
2180 let response_evidence = evidence_ids
2181 .iter()
2182 .filter_map(|id| knowledge.inspect_evidence(id, Clone::clone))
2183 .collect::<Vec<_>>();
2184 if response_evidence.len() != evidence_ids.len() {
2185 return false;
2186 }
2187 let Some(expected_lineage_parents) = secret_exposure_response_parent_ids(
2188 &response_evidence,
2189 expected_subject,
2190 &request_correlation,
2191 ) else {
2192 return false;
2193 };
2194 evidence_ids
2195 .iter()
2196 .filter_map(|id| {
2197 knowledge.inspect_evidence(id, |evidence| {
2198 (evidence.predicate().namespace() == SECRET_EXPOSURE_NAMESPACE
2199 && evidence.predicate().name() == RESPONSE_BODY_LINEAGE)
2200 .then(|| {
2201 is_secret_exposure_response_lineage(
2202 evidence,
2203 expected_subject,
2204 &request_correlation,
2205 &expected_lineage_parents,
2206 )
2207 })
2208 })?
2209 })
2210 .collect::<Vec<_>>()
2211 };
2212 #[cfg(not(feature = "secret-exposure-review"))]
2213 let lineages: Vec<bool> = Vec::new();
2214
2215 match (digests.as_slice(), lineages.as_slice(), expected_sha256) {
2216 ([EvidenceValue::Text(digest)], [], expected) => {
2217 digest.len() == 64
2218 && digest
2219 .bytes()
2220 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2221 && expected.is_none_or(|expected| digest.as_str() == hex_sha256(expected))
2222 },
2223 ([], [true], None) => true,
2224 _ => false,
2225 }
2226}
2227
2228#[cfg(test)]
2229mod tests {
2230 use termivar_core::EvidenceValue;
2231
2232 use super::{
2233 asset_media_type_is_supported, asset_path_reference, candidate_binding_evidence,
2234 committed_response_base_is_valid, fetched_representation_evidence, looks_like_html,
2235 opaque_url_reference, root_representation_binding_evidence,
2236 selected_variants_completely_interpreted, source_page_capacity_allows,
2237 summarize_reused_representations, CommittedRepresentation,
2238 WordPressAssetFingerprintCollector, WordPressAssetFingerprintRuntimeError,
2239 WordPressComponentIdentity, WordPressComponentKind, WordPressDiscoveryAssociation,
2240 WordPressObservedAssetCandidate, WordPressObservedAssetFingerprint,
2241 WordPressSelectedObservedAsset, ASSET_FETCHED_ROOT_DERIVATION,
2242 MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES,
2243 };
2244 #[cfg(feature = "secret-exposure-review")]
2245 use crate::web_runtime::secret_exposure::RESPONSE_BODY_LINEAGE;
2246 use crate::wordpress_review::parse_wordpress_asset_fingerprint_catalog;
2247 #[cfg(feature = "secret-exposure-review")]
2248 use crate::{KnowledgeBase, HTTP_EVIDENCE_EXECUTOR_ID};
2249 use std::collections::BTreeSet;
2250 use std::sync::Arc;
2251 use termivar_core::{ConfidenceScore, EntityId, EvidenceId};
2252 #[cfg(feature = "secret-exposure-review")]
2253 use termivar_core::{
2254 DerivationAlgorithm, Evidence, EvidenceDerivation, EvidenceKind, EvidenceSource,
2255 HttpEvidencePredicate, KnowledgePredicate,
2256 };
2257 use url::Url;
2258
2259 #[test]
2260 fn observed_asset_candidate_preserves_exact_safe_query_and_frozen_binding() {
2261 let application = Url::parse("https://example.test/blog/").unwrap();
2262 let role = application.join("wp-content/plugins/").unwrap();
2263 let document = application.join("contact/").unwrap();
2264 let target = role
2265 .join("sample-plugin/assets/app.js?ver=release-1_2")
2266 .unwrap();
2267 let component =
2268 WordPressComponentIdentity::new(WordPressComponentKind::Plugin, "sample-plugin")
2269 .unwrap();
2270 let page_reference = opaque_url_reference("wordpress-discovery-page", &document);
2271 let candidate = WordPressObservedAssetCandidate::from_html_reference(
2272 &application,
2273 &role,
2274 component.clone(),
2275 WordPressDiscoveryAssociation::ObservedConventional,
2276 &document,
2277 &document,
2278 "/blog/wp-content/plugins/sample-plugin/assets/app.js?ver=release-1_2",
2279 &target,
2280 "assets/app.js",
2281 page_reference,
2282 )
2283 .unwrap();
2284 assert_eq!(candidate.target_url(), &target);
2285 assert_eq!(candidate.component(), &component);
2286 assert_eq!(candidate.relative_path(), "assets/app.js");
2287
2288 let forged = WordPressObservedAssetCandidate::from_html_reference(
2289 &application,
2290 &role,
2291 component,
2292 WordPressDiscoveryAssociation::ObservedConventional,
2293 &document,
2294 &document,
2295 "/blog/wp-content/plugins/sample-plugin/assets/app.js?ver=release-1_2",
2296 &role.join("sample-plugin/assets/other.js").unwrap(),
2297 "assets/app.js",
2298 opaque_url_reference("wordpress-discovery-page", &document),
2299 );
2300 assert_eq!(
2301 forged,
2302 Err(WordPressAssetFingerprintRuntimeError::InvalidCandidate)
2303 );
2304
2305 let custom_role = Url::parse("https://example.test/modules/").unwrap();
2306 let custom_target = custom_role
2307 .join("sample-plugin/assets/app.js?ver=release-1_2")
2308 .unwrap();
2309 let custom_reference = "/modules/sample-plugin/assets/app.js?ver=release-1_2";
2310 WordPressObservedAssetCandidate::from_html_reference(
2311 &application,
2312 &custom_role,
2313 WordPressComponentIdentity::new(WordPressComponentKind::Plugin, "sample-plugin")
2314 .unwrap(),
2315 WordPressDiscoveryAssociation::ExplicitOperator,
2316 &document,
2317 &document,
2318 custom_reference,
2319 &custom_target,
2320 "assets/app.js",
2321 opaque_url_reference("wordpress-discovery-page", &document),
2322 )
2323 .unwrap();
2324 assert!(matches!(
2325 WordPressObservedAssetCandidate::from_html_reference(
2326 &application,
2327 &custom_role,
2328 WordPressComponentIdentity::new(WordPressComponentKind::Plugin, "sample-plugin",)
2329 .unwrap(),
2330 WordPressDiscoveryAssociation::ObservedConventional,
2331 &document,
2332 &document,
2333 custom_reference,
2334 &custom_target,
2335 "assets/app.js",
2336 opaque_url_reference("wordpress-discovery-page", &document),
2337 ),
2338 Err(WordPressAssetFingerprintRuntimeError::InvalidCandidate)
2339 ));
2340 }
2341
2342 #[test]
2343 fn staging_limit_plus_one_records_incomplete_candidate_identity_coverage() {
2344 let catalogue = parse_wordpress_asset_fingerprint_catalog(include_bytes!(
2345 "../../../../docs/examples/wordpress-review/asset-fingerprints/catalogue.synthetic.json"
2346 ))
2347 .unwrap();
2348 let collector = WordPressAssetFingerprintCollector::new(Arc::new(catalogue));
2349 let application = Url::parse("https://example.test/").unwrap();
2350 let role = application.join("wp-content/plugins/").unwrap();
2351 let component = WordPressComponentIdentity::new(
2352 WordPressComponentKind::Plugin,
2353 "termivar-fingerprint-lab",
2354 )
2355 .unwrap();
2356 let candidates = (0..=MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES)
2357 .map(|ordinal| {
2358 let raw = format!(
2359 "/wp-content/plugins/termivar-fingerprint-lab/assets/fingerprint.js?ver=v{ordinal}"
2360 );
2361 let target = application.join(&raw).unwrap();
2362 WordPressObservedAssetCandidate::from_html_reference(
2363 &application,
2364 &role,
2365 component.clone(),
2366 WordPressDiscoveryAssociation::ObservedConventional,
2367 &application,
2368 &application,
2369 &raw,
2370 &target,
2371 "assets/fingerprint.js",
2372 opaque_url_reference("wordpress-discovery-page", &application),
2373 )
2374 .unwrap()
2375 })
2376 .collect::<Vec<_>>();
2377 collector
2378 .stage_candidates(&application, candidates, vec![EvidenceId::new()], false)
2379 .unwrap();
2380
2381 let state = collector
2382 .0
2383 .lock()
2384 .unwrap_or_else(|poisoned| poisoned.into_inner());
2385 let staged = state.pending_candidates.get(application.as_str()).unwrap();
2386 assert_eq!(
2387 staged.candidates.len(),
2388 MAX_WORDPRESS_ASSET_FINGERPRINT_CANDIDATE_IDENTITIES
2389 );
2390 assert!(staged.source_limit_exceeded);
2391 }
2392
2393 #[test]
2394 fn asset_representation_requires_closed_mime_and_rejects_obvious_html() {
2395 assert!(asset_media_type_is_supported("asset.css", Some("text/css")));
2396 assert!(asset_media_type_is_supported(
2397 "asset.js",
2398 Some("text/javascript")
2399 ));
2400 assert!(asset_media_type_is_supported(
2401 "asset.js",
2402 Some("application/javascript")
2403 ));
2404 assert!(!asset_media_type_is_supported(
2405 "asset.css",
2406 Some("text/html")
2407 ));
2408 assert!(!asset_media_type_is_supported(
2409 "asset.js",
2410 Some("application/octet-stream")
2411 ));
2412 assert!(!asset_media_type_is_supported("asset.js", None));
2413
2414 assert!(looks_like_html(b" \r\n<!DOCTYPE HTML><html>"));
2415 assert!(looks_like_html(b"\xef\xbb\xbf<HTML><body>login</body>"));
2416 assert!(!looks_like_html(b"const marker = '<html>';"));
2417 assert!(!looks_like_html(b"body { content: '<html>'; }"));
2418 }
2419
2420 #[test]
2421 fn query_variant_ambiguity_prevents_complete_path_interpretation() {
2422 let application = Url::parse("https://example.test/blog/").unwrap();
2423 let role = application.join("wp-content/plugins/").unwrap();
2424 let component =
2425 WordPressComponentIdentity::new(WordPressComponentKind::Plugin, "sample-plugin")
2426 .unwrap();
2427 let selected = |query: &str| WordPressSelectedObservedAsset {
2428 application_url: application.clone(),
2429 role_base_url: role.clone(),
2430 target_url: role
2431 .join(&format!("sample-plugin/assets/app.js?ver={query}"))
2432 .unwrap(),
2433 component: component.clone(),
2434 association: WordPressDiscoveryAssociation::ObservedConventional,
2435 relative_path: "assets/app.js".to_owned(),
2436 resource_reference: format!("resource-{query}"),
2437 source_page_references: vec![format!("page-{query}")],
2438 source_evidence_ids: vec![EvidenceId::new()],
2439 };
2440 let first = selected("one");
2441 let second = selected("two");
2442 let representation = |selected: WordPressSelectedObservedAsset| CommittedRepresentation {
2443 observation: WordPressObservedAssetFingerprint::new(
2444 component.clone(),
2445 "assets/app.js",
2446 3,
2447 [7; 32],
2448 )
2449 .unwrap(),
2450 selected,
2451 root_evidence_id: EvidenceId::new(),
2452 };
2453
2454 assert!(selected_variants_completely_interpreted(
2455 std::slice::from_ref(&first),
2456 &[representation(first.clone())],
2457 1,
2458 ));
2459
2460 assert!(!selected_variants_completely_interpreted(
2461 &[first.clone(), second.clone()],
2462 &[representation(first.clone())],
2463 2,
2464 ));
2465 assert!(!selected_variants_completely_interpreted(
2466 &[first.clone(), second.clone()],
2467 &[representation(first), representation(second)],
2468 2,
2469 ));
2470 assert!(!selected_variants_completely_interpreted(
2471 &[selected("one"), selected("two")],
2472 &[
2473 representation(selected("one")),
2474 representation(selected("two")),
2475 ],
2476 33,
2477 ));
2478 }
2479
2480 #[cfg(feature = "secret-exposure-review")]
2481 #[test]
2482 fn committed_response_base_accepts_exactly_one_body_identity_lineage() {
2483 let subject = EntityId::new("endpoint:https://example.test/blog/").unwrap();
2484 let url = Url::parse("https://example.test/blog/").unwrap();
2485 let correlation = "case:wordpress-fingerprint-lineage";
2486 let base_record = |kind, method, predicate, value| {
2487 Evidence::new(
2488 subject.clone(),
2489 kind,
2490 predicate,
2491 value,
2492 EvidenceSource::new(HTTP_EVIDENCE_EXECUTOR_ID, method)
2493 .unwrap()
2494 .with_correlation_id(correlation)
2495 .unwrap(),
2496 ConfidenceScore::from_percent(100).unwrap(),
2497 )
2498 };
2499 let base = vec![
2500 base_record(
2501 EvidenceKind::Http,
2502 "request-method",
2503 HttpEvidencePredicate::REQUEST_METHOD.into_knowledge(),
2504 EvidenceValue::Text("GET".to_owned()),
2505 ),
2506 base_record(
2507 EvidenceKind::Http,
2508 "request-url",
2509 HttpEvidencePredicate::REQUEST_URL.into_knowledge(),
2510 EvidenceValue::Text(url.to_string()),
2511 ),
2512 base_record(
2513 EvidenceKind::Http,
2514 "response-status",
2515 HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
2516 EvidenceValue::Unsigned(200),
2517 ),
2518 base_record(
2519 EvidenceKind::Http,
2520 "response-final-url",
2521 HttpEvidencePredicate::RESPONSE_FINAL_URL.into_knowledge(),
2522 EvidenceValue::Text(url.to_string()),
2523 ),
2524 base_record(
2525 EvidenceKind::Http,
2526 "response-media-type",
2527 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge(),
2528 EvidenceValue::Text("text/html".to_owned()),
2529 ),
2530 base_record(
2531 EvidenceKind::Content,
2532 "response-body-size",
2533 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
2534 EvidenceValue::Unsigned(41),
2535 ),
2536 base_record(
2537 EvidenceKind::Content,
2538 "response-body-truncation",
2539 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
2540 EvidenceValue::Boolean(false),
2541 ),
2542 ];
2543 let digest = |value| {
2544 Evidence::new(
2545 subject.clone(),
2546 EvidenceKind::Content,
2547 HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
2548 value,
2549 EvidenceSource::new(HTTP_EVIDENCE_EXECUTOR_ID, "response-body-sha256")
2550 .unwrap()
2551 .with_correlation_id(correlation)
2552 .unwrap(),
2553 ConfidenceScore::from_percent(100).unwrap(),
2554 )
2555 };
2556 let lineage = || {
2557 Evidence::new(
2558 subject.clone(),
2559 EvidenceKind::Custom("passive-secret-exposure".to_owned()),
2560 KnowledgePredicate::new("web.secret-exposure", RESPONSE_BODY_LINEAGE).unwrap(),
2561 EvidenceValue::Text("evaluated".to_owned()),
2562 EvidenceSource::new(
2563 HTTP_EVIDENCE_EXECUTOR_ID,
2564 "bounded-value-free-secret-detection",
2565 )
2566 .unwrap()
2567 .with_correlation_id(correlation)
2568 .unwrap(),
2569 ConfidenceScore::from_percent(100).unwrap(),
2570 )
2571 .derived_from(
2572 EvidenceDerivation::new(
2573 base.iter()
2574 .map(|item| item.id().clone())
2575 .collect::<Vec<_>>(),
2576 DerivationAlgorithm::new("web.secret-exposure.fixed-catalogue", 1).unwrap(),
2577 )
2578 .unwrap(),
2579 )
2580 };
2581 let validates = |body_identity: Vec<Evidence>| {
2582 let knowledge = KnowledgeBase::new();
2583 let mut evidence = base.clone();
2584 evidence.extend(body_identity);
2585 let evidence_ids = evidence
2586 .iter()
2587 .map(|item| item.id().clone())
2588 .collect::<Vec<_>>();
2589 knowledge.insert_evidence_batch(evidence).unwrap();
2590 committed_response_base_is_valid(
2591 &knowledge,
2592 &evidence_ids,
2593 &subject,
2594 &url,
2595 |media_type| media_type == "text/html",
2596 Some(41),
2597 None,
2598 )
2599 };
2600
2601 assert!(validates(vec![digest(EvidenceValue::Text("a".repeat(64)))]));
2602 assert!(validates(vec![lineage()]));
2603 assert!(!validates(vec![
2604 digest(EvidenceValue::Text("a".repeat(64))),
2605 lineage(),
2606 ]));
2607 assert!(!validates(vec![
2608 digest(EvidenceValue::Text("a".repeat(64))),
2609 digest(EvidenceValue::Text("a".repeat(64))),
2610 ]));
2611 assert!(!validates(vec![lineage(), lineage()]));
2612 assert!(!validates(vec![digest(EvidenceValue::Unsigned(64))]));
2613 }
2614
2615 #[test]
2616 fn reused_receipt_keeps_its_target_and_evidence_witness_atomic() {
2617 let application = Url::parse("https://example.test/blog/").unwrap();
2618 let role = application.join("wp-content/plugins/").unwrap();
2619 let component =
2620 WordPressComponentIdentity::new(WordPressComponentKind::Plugin, "sample-plugin")
2621 .unwrap();
2622 let selected = |query: &str| WordPressSelectedObservedAsset {
2623 application_url: application.clone(),
2624 role_base_url: role.clone(),
2625 target_url: role
2626 .join(&format!("sample-plugin/assets/app.js?ver={query}"))
2627 .unwrap(),
2628 component: component.clone(),
2629 association: WordPressDiscoveryAssociation::ObservedConventional,
2630 relative_path: "assets/app.js".to_owned(),
2631 resource_reference: "resource-path".to_owned(),
2632 source_page_references: vec![format!("page-{query}")],
2633 source_evidence_ids: vec![EvidenceId::new()],
2634 };
2635 let observation = || {
2636 WordPressObservedAssetFingerprint::new(component.clone(), "assets/app.js", 3, [7; 32])
2637 .unwrap()
2638 };
2639 let first_target = selected("a");
2640 let expected_target = first_target.target_url.clone();
2641 let representations = vec![
2642 CommittedRepresentation {
2643 selected: first_target,
2644 observation: observation(),
2645 root_evidence_id: EvidenceId::parse("z-evidence").unwrap(),
2646 },
2647 CommittedRepresentation {
2648 selected: selected("z"),
2649 observation: observation(),
2650 root_evidence_id: EvidenceId::parse("a-evidence").unwrap(),
2651 },
2652 ];
2653
2654 let (receipt_target, observations, evidence_ids) =
2655 summarize_reused_representations(representations);
2656 assert_eq!(receipt_target, expected_target);
2657 assert_eq!(observations.len(), 1);
2658 assert_eq!(evidence_ids.len(), 1);
2659 assert_eq!(evidence_ids[0].as_str(), "z-evidence");
2660 }
2661
2662 #[test]
2663 fn root_asset_bindings_respect_the_same_subject_derivation_boundary() {
2664 let application = Url::parse("https://example.test/blog/").unwrap();
2665 let role = application.join("wp-content/plugins/").unwrap();
2666 let document = application.join("contact/").unwrap();
2667 let target = role.join("sample-plugin/assets/app.js?ver=one").unwrap();
2668 let component =
2669 WordPressComponentIdentity::new(WordPressComponentKind::Plugin, "sample-plugin")
2670 .unwrap();
2671 let candidate = WordPressObservedAssetCandidate::from_html_reference(
2672 &application,
2673 &role,
2674 component.clone(),
2675 WordPressDiscoveryAssociation::ObservedConventional,
2676 &document,
2677 &document,
2678 "/blog/wp-content/plugins/sample-plugin/assets/app.js?ver=one",
2679 &target,
2680 "assets/app.js",
2681 opaque_url_reference("wordpress-discovery-page", &document),
2682 )
2683 .unwrap();
2684 let root = EntityId::new("endpoint:https://example.test/blog/").unwrap();
2685 let nomination = candidate_binding_evidence(&root, &candidate).unwrap();
2686 assert_eq!(nomination.subject(), &root);
2687 assert!(nomination.origin().derivation().is_none());
2688 assert_eq!(
2689 nomination.source().method(),
2690 "committed-html-resource-nomination"
2691 );
2692
2693 let nomination_parents = vec![EvidenceId::new(), EvidenceId::new()];
2694 let selected = WordPressSelectedObservedAsset {
2695 application_url: application,
2696 role_base_url: role,
2697 target_url: target,
2698 component: component.clone(),
2699 association: WordPressDiscoveryAssociation::ObservedConventional,
2700 relative_path: "assets/app.js".to_owned(),
2701 resource_reference: "resource-one".to_owned(),
2702 source_page_references: vec!["page-one".to_owned()],
2703 source_evidence_ids: nomination_parents.clone(),
2704 };
2705 let observation =
2706 WordPressObservedAssetFingerprint::new(component, "assets/app.js", 3, [7; 32]).unwrap();
2707 let endpoint_parent = EvidenceId::new();
2708 let reused = root_representation_binding_evidence(
2709 &root,
2710 &selected,
2711 &observation,
2712 &endpoint_parent,
2713 "reused-complete-response",
2714 )
2715 .unwrap();
2716 assert_eq!(reused.subject(), &root);
2717 assert!(reused.origin().derivation().is_none());
2718 assert_eq!(reused.source().method(), "reused-complete-response");
2719 assert!(
2720 matches!(reused.value(), EvidenceValue::Text(value) if value.contains("source-binding:sha256:"))
2721 );
2722
2723 let fetched = fetched_representation_evidence(
2724 &root,
2725 &selected,
2726 &observation,
2727 ConfidenceScore::from_percent(80).unwrap(),
2728 )
2729 .unwrap();
2730 let fetched_derivation = fetched.origin().derivation().unwrap();
2731 assert_eq!(
2732 fetched_derivation.algorithm().name(),
2733 ASSET_FETCHED_ROOT_DERIVATION
2734 );
2735 assert_eq!(
2736 fetched_derivation
2737 .parents()
2738 .iter()
2739 .cloned()
2740 .collect::<BTreeSet<_>>(),
2741 nomination_parents.into_iter().collect::<BTreeSet<_>>()
2742 );
2743 }
2744
2745 #[test]
2746 fn one_resource_has_a_closed_source_page_binding_limit() {
2747 let application = Url::parse("https://example.test/blog/").unwrap();
2748 let role = application.join("wp-content/plugins/").unwrap();
2749 let target = role.join("sample-plugin/assets/app.js").unwrap();
2750 let component =
2751 WordPressComponentIdentity::new(WordPressComponentKind::Plugin, "sample-plugin")
2752 .unwrap();
2753 let candidates = (0..9)
2754 .map(|ordinal| {
2755 let document = application.join(&format!("page-{ordinal}/")).unwrap();
2756 WordPressObservedAssetCandidate::from_html_reference(
2757 &application,
2758 &role,
2759 component.clone(),
2760 WordPressDiscoveryAssociation::ObservedConventional,
2761 &document,
2762 &document,
2763 "/blog/wp-content/plugins/sample-plugin/assets/app.js",
2764 &target,
2765 "assets/app.js",
2766 opaque_url_reference("wordpress-discovery-page", &document),
2767 )
2768 .unwrap()
2769 })
2770 .collect::<Vec<_>>();
2771 let existing = std::collections::BTreeMap::new();
2772
2773 assert!(source_page_capacity_allows(&existing, &candidates[..4]));
2774 for count in [5, 8, 9] {
2775 assert!(
2776 !source_page_capacity_allows(&existing, &candidates[..count]),
2777 "{count} distinct page bindings must stop before report projection"
2778 );
2779 }
2780
2781 let query_variants = (0..5)
2782 .map(|ordinal| {
2783 let document = application
2784 .join(&format!("variant-page-{ordinal}/"))
2785 .unwrap();
2786 let query = if ordinal % 2 == 0 { "one" } else { "two" };
2787 let variant_target = role
2788 .join(&format!("sample-plugin/assets/app.js?ver={query}"))
2789 .unwrap();
2790 WordPressObservedAssetCandidate::from_html_reference(
2791 &application,
2792 &role,
2793 component.clone(),
2794 WordPressDiscoveryAssociation::ObservedConventional,
2795 &document,
2796 &document,
2797 &format!("/blog/wp-content/plugins/sample-plugin/assets/app.js?ver={query}"),
2798 &variant_target,
2799 "assets/app.js",
2800 opaque_url_reference("wordpress-discovery-page", &document),
2801 )
2802 .unwrap()
2803 })
2804 .collect::<Vec<_>>();
2805 assert!(source_page_capacity_allows(&existing, &query_variants[..4]));
2806 assert!(!source_page_capacity_allows(&existing, &query_variants));
2807 }
2808
2809 #[test]
2810 fn resource_reference_identifies_the_scoped_path_not_a_query_variant() {
2811 let application = Url::parse("https://example.test/blog/").unwrap();
2812 let component =
2813 WordPressComponentIdentity::new(WordPressComponentKind::Plugin, "sample-plugin")
2814 .unwrap();
2815 let reference = asset_path_reference(&application, &component, "assets/app.js");
2816
2817 assert!(reference.starts_with("sha256:"));
2818 assert_eq!(reference.len(), 71);
2819 assert_eq!(
2820 reference,
2821 asset_path_reference(&application, &component, "assets/app.js")
2822 );
2823 assert_ne!(
2824 reference,
2825 asset_path_reference(&application, &component, "assets/app.css")
2826 );
2827 assert_ne!(
2828 reference,
2829 asset_path_reference(
2830 &Url::parse("https://example.test/other/").unwrap(),
2831 &component,
2832 "assets/app.js"
2833 )
2834 );
2835 }
2836}