Skip to main content

termivar_scanner/web_runtime/
wordpress_fingerprint_runtime.rs

1//! Bounded runtime admission and collection for observed WordPress asset
2//! fingerprints.
3//!
4//! The runtime is intentionally separate from catalogue parsing and report
5//! rendering. It turns only committed HTML nominations or complete anonymous
6//! response observations into pure matcher inputs.
7
8use 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/// One exact JS/CSS URL admitted from the same DOM traversal as ordinary
81/// WordPress discovery. It carries no request authority until its source HTML
82/// evidence is committed and revalidated by the collector.
83#[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/// Assessment-owned, bounded staging sink. Clones share only this assessment's
229/// inert catalogue, nominated URLs, and completed fingerprint facts.
230#[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    /// Revalidates the complete source-HTML evidence after the decision
301    /// transaction commits, then mints one root-scoped nomination binding.
302    /// Catalogue entries are only a filter here and never nominate a URL.
303    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    /// Returns one currently selected exact representation, if the URL was
476    /// admitted from HTML and its component/path survives the bounded stable
477    /// selection. All query variants of a selected path remain observable so
478    /// conflicting bytes cannot be hidden by choosing a favourite response.
479    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    /// Records an ordinary same-run GET attempt. A failed or incompatible GET
493    /// suppresses a later fingerprint-owned retry for the same selected path.
494    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    /// Projects one eligible ordinary GET into a derived endpoint-scoped
514    /// evidence record. The complete bytes are borrowed only long enough to
515    /// hash; the staged state retains length/digest and exact evidence IDs.
516    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    /// Confirms that the derived endpoint evidence and all eight base response
609    /// facts committed unchanged, then creates one bounded root-scoped binding.
610    /// The raw response bytes are no longer needed after this point.
611    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            // The candidate ledger already retains the true variant count.
652            // Additional complete observations are deliberately not projected,
653            // but their existence keeps this path from becoming a strong
654            // single-representation candidate.
655            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/// Exact committed candidate exposed to the generic response observer and the
767/// later acquisition phase. Target queries remain private and are never
768/// copied into report-facing values.
769#[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    // The committed HTML response may belong to a secondary page subject,
1888    // while the fingerprint audit belongs to the application root. Knowledge
1889    // derivations deliberately reject cross-subject parents, so this sealed
1890    // binding is a direct root-scoped fact minted only after the exact page
1891    // subject, URL, and complete-response parents were revalidated.
1892    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    // The ordinary response evidence belongs to the asset endpoint subject.
1939    // Its complete exact representation was revalidated before this direct
1940    // root-scoped binding was minted, avoiding an invalid cross-subject
1941    // KnowledgeBase derivation while retaining the evidence ID in the sealed
1942    // value below.
1943    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}