Skip to main content

termivar_scanner/wordpress_review/
asset_fingerprint.rs

1//! Bounded local WordPress asset fingerprint catalogues and pure matching.
2//!
3//! A catalogue is an operator-supplied finite reference set. Parsing it does
4//! not authenticate its publisher, establish that it covers every release, or
5//! grant permission to request any target resource. The matcher accepts only
6//! already-qualified complete-byte observations and never promotes a listed
7//! release to installed-version evidence.
8
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    mem::size_of,
12};
13
14use serde::Deserialize;
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17use url::Url;
18
19use super::{
20    parse_bounded_json, WordPressComponentIdentity, WordPressComponentKind, WordPressReviewError,
21    MAX_CATALOG_JSON_NODES, MAX_WORDPRESS_IDENTIFIER_BYTES, MAX_WORDPRESS_REFERENCE_BYTES,
22    MAX_WORDPRESS_VERSION_BYTES,
23};
24
25pub const WORDPRESS_ASSET_FINGERPRINT_CATALOG_SCHEMA: &str =
26    "security.wordpress-asset-fingerprint-catalog/v1";
27pub const WORDPRESS_ASSET_FINGERPRINT_REPRESENTATION_PROFILE: &str = "identity-content-bytes/v1";
28pub const MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_BYTES: usize = 4 * 1024 * 1024;
29pub const MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_COMPONENTS: usize = 16;
30pub const MAX_WORDPRESS_ASSET_FINGERPRINT_RELEASES_PER_COMPONENT: usize = 128;
31pub const MAX_WORDPRESS_ASSET_FINGERPRINT_FILES_PER_RELEASE: usize = 32;
32pub const MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_FILES: usize = 16_384;
33pub const MAX_WORDPRESS_ASSET_FINGERPRINT_RETAINED_BYTES: usize = 16 * 1024 * 1024;
34pub const MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES: u64 = 512 * 1024;
35pub const MAX_WORDPRESS_ASSET_FINGERPRINT_RESOURCES_PER_COMPONENT: usize = 2;
36const MAX_WORDPRESS_ASSET_FINGERPRINT_OBSERVATIONS_PER_COMPONENT: usize = 32;
37pub const MAX_WORDPRESS_ASSET_FINGERPRINT_PATH_BYTES: usize = 512;
38const MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICES: usize = 32;
39const MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICE_BYTES: usize = 2_048;
40const CONSERVATIVE_ALLOCATION_OVERHEAD: usize = 64;
41
42/// Fail-closed errors for catalogue parsing and pure candidate matching.
43#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
44pub enum WordPressAssetFingerprintError {
45    #[error("WordPress asset fingerprint catalogue is empty")]
46    EmptyInput,
47    #[error("WordPress asset fingerprint catalogue exceeds its byte limit")]
48    InputTooLarge,
49    #[error("WordPress asset fingerprint catalogue contains a duplicate object key")]
50    DuplicateKey,
51    #[error("WordPress asset fingerprint catalogue exceeds a structural limit")]
52    StructuralLimitExceeded,
53    #[error("WordPress asset fingerprint catalogue is malformed JSON")]
54    MalformedJson,
55    #[error("WordPress asset fingerprint catalogue uses an unsupported schema")]
56    UnsupportedSchema,
57    #[error("WordPress asset fingerprint catalogue contains an invalid value")]
58    InvalidCatalog,
59    #[error("WordPress asset fingerprint catalogue contains a duplicate identity")]
60    DuplicateIdentity,
61    #[error("WordPress asset fingerprint catalogue exceeds its retained-data limit")]
62    RetainedDataTooLarge,
63    #[error("observed WordPress asset fingerprints violate the matching contract")]
64    InvalidObservation,
65    #[error("observed WordPress asset fingerprints exceed the matching limit")]
66    MatchLimitExceeded,
67}
68
69/// Exact local-input identity plus the prepared finite reference set.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct WordPressAssetFingerprintCatalogImport {
72    byte_length: u64,
73    sha256: [u8; 32],
74    semantic_sha256: [u8; 32],
75    retained_bytes: usize,
76    release_count: usize,
77    file_count: usize,
78    catalog: WordPressAssetFingerprintCatalogMetadata,
79    components: Vec<WordPressAssetFingerprintComponent>,
80}
81
82pub type WordPressAssetFingerprintCatalog = WordPressAssetFingerprintCatalogImport;
83
84impl WordPressAssetFingerprintCatalogImport {
85    #[must_use]
86    pub const fn byte_length(&self) -> u64 {
87        self.byte_length
88    }
89
90    #[must_use]
91    pub const fn sha256(&self) -> &[u8; 32] {
92        &self.sha256
93    }
94
95    #[must_use]
96    pub const fn semantic_sha256(&self) -> &[u8; 32] {
97        &self.semantic_sha256
98    }
99
100    #[must_use]
101    pub const fn retained_bytes(&self) -> usize {
102        self.retained_bytes
103    }
104
105    #[must_use]
106    pub const fn release_count(&self) -> usize {
107        self.release_count
108    }
109
110    #[must_use]
111    pub const fn file_count(&self) -> usize {
112        self.file_count
113    }
114
115    #[must_use]
116    pub const fn catalog(&self) -> &WordPressAssetFingerprintCatalogMetadata {
117        &self.catalog
118    }
119
120    #[must_use]
121    pub fn components(&self) -> &[WordPressAssetFingerprintComponent] {
122        &self.components
123    }
124
125    #[must_use]
126    pub fn component(
127        &self,
128        identity: &WordPressComponentIdentity,
129    ) -> Option<&WordPressAssetFingerprintComponent> {
130        self.components
131            .binary_search_by(|candidate| candidate.identity.cmp(identity))
132            .ok()
133            .map(|index| &self.components[index])
134    }
135}
136
137/// Catalogue-level identity and inert source provenance.
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct WordPressAssetFingerprintCatalogMetadata {
140    id: String,
141    revision: String,
142    source_namespace: String,
143    provenance: WordPressAssetFingerprintProvenance,
144}
145
146impl WordPressAssetFingerprintCatalogMetadata {
147    #[must_use]
148    pub fn id(&self) -> &str {
149        &self.id
150    }
151
152    #[must_use]
153    pub fn revision(&self) -> &str {
154        &self.revision
155    }
156
157    #[must_use]
158    pub fn source_namespace(&self) -> &str {
159        &self.source_namespace
160    }
161
162    #[must_use]
163    pub const fn provenance(&self) -> &WordPressAssetFingerprintProvenance {
164        &self.provenance
165    }
166}
167
168/// Inert bibliographic provenance supplied with a finite catalogue.
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct WordPressAssetFingerprintProvenance {
171    reference: String,
172    revision: String,
173    notices: Vec<WordPressAssetFingerprintNotice>,
174}
175
176impl WordPressAssetFingerprintProvenance {
177    #[must_use]
178    pub fn reference(&self) -> &str {
179        &self.reference
180    }
181
182    #[must_use]
183    pub fn revision(&self) -> &str {
184        &self.revision
185    }
186
187    #[must_use]
188    pub fn notices(&self) -> &[WordPressAssetFingerprintNotice] {
189        &self.notices
190    }
191}
192
193/// Attribution retained as inert text; it is never fetched or executed.
194#[derive(Clone, Debug, Eq, PartialEq)]
195pub struct WordPressAssetFingerprintNotice {
196    id: String,
197    party: String,
198    notice: String,
199    license: String,
200    license_reference: String,
201}
202
203impl WordPressAssetFingerprintNotice {
204    #[must_use]
205    pub fn id(&self) -> &str {
206        &self.id
207    }
208
209    #[must_use]
210    pub fn party(&self) -> &str {
211        &self.party
212    }
213
214    #[must_use]
215    pub fn notice(&self) -> &str {
216        &self.notice
217    }
218
219    #[must_use]
220    pub fn license(&self) -> &str {
221        &self.license
222    }
223
224    #[must_use]
225    pub fn license_reference(&self) -> &str {
226        &self.license_reference
227    }
228}
229
230/// One exact plugin/theme identity represented by the finite catalogue.
231#[derive(Clone, Debug, Eq, PartialEq)]
232pub struct WordPressAssetFingerprintComponent {
233    identity: WordPressComponentIdentity,
234    releases: Vec<WordPressAssetFingerprintRelease>,
235}
236
237impl WordPressAssetFingerprintComponent {
238    #[must_use]
239    pub const fn identity(&self) -> &WordPressComponentIdentity {
240        &self.identity
241    }
242
243    #[must_use]
244    pub fn releases(&self) -> &[WordPressAssetFingerprintRelease] {
245        &self.releases
246    }
247}
248
249/// One opaque listed release/build and its exact reference files.
250#[derive(Clone, Debug, Eq, PartialEq)]
251pub struct WordPressAssetFingerprintRelease {
252    release_id: String,
253    version: String,
254    build_variant: Option<String>,
255    source: WordPressAssetFingerprintReleaseSource,
256    files: Vec<WordPressAssetFingerprintFile>,
257}
258
259impl WordPressAssetFingerprintRelease {
260    #[must_use]
261    pub fn release_id(&self) -> &str {
262        &self.release_id
263    }
264
265    #[must_use]
266    pub fn version(&self) -> &str {
267        &self.version
268    }
269
270    #[must_use]
271    pub fn build_variant(&self) -> Option<&str> {
272        self.build_variant.as_deref()
273    }
274
275    #[must_use]
276    pub const fn source(&self) -> &WordPressAssetFingerprintReleaseSource {
277        &self.source
278    }
279
280    #[must_use]
281    pub fn files(&self) -> &[WordPressAssetFingerprintFile] {
282        &self.files
283    }
284
285    #[must_use]
286    pub fn file(&self, path: &str) -> Option<&WordPressAssetFingerprintFile> {
287        self.files
288            .binary_search_by(|candidate| candidate.path.as_str().cmp(path))
289            .ok()
290            .map(|index| &self.files[index])
291    }
292}
293
294/// Inert per-release bibliography and attribution references.
295#[derive(Clone, Debug, Eq, PartialEq)]
296pub struct WordPressAssetFingerprintReleaseSource {
297    reference: String,
298    revision: String,
299    notice_ids: Vec<String>,
300}
301
302impl WordPressAssetFingerprintReleaseSource {
303    #[must_use]
304    pub fn reference(&self) -> &str {
305        &self.reference
306    }
307
308    #[must_use]
309    pub fn revision(&self) -> &str {
310        &self.revision
311    }
312
313    #[must_use]
314    pub fn notice_ids(&self) -> &[String] {
315        &self.notice_ids
316    }
317}
318
319/// One complete identity-content byte reference.
320#[derive(Clone, Debug, Eq, PartialEq)]
321pub struct WordPressAssetFingerprintFile {
322    path: String,
323    byte_length: u64,
324    sha256: [u8; 32],
325    representation_profile: WordPressAssetFingerprintRepresentationProfile,
326}
327
328impl WordPressAssetFingerprintFile {
329    #[must_use]
330    pub fn path(&self) -> &str {
331        &self.path
332    }
333
334    #[must_use]
335    pub const fn byte_length(&self) -> u64 {
336        self.byte_length
337    }
338
339    #[must_use]
340    pub const fn sha256(&self) -> &[u8; 32] {
341        &self.sha256
342    }
343
344    #[must_use]
345    pub const fn representation_profile(&self) -> WordPressAssetFingerprintRepresentationProfile {
346        self.representation_profile
347    }
348}
349
350/// The only byte representation understood by catalogue v1.
351#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
352pub enum WordPressAssetFingerprintRepresentationProfile {
353    IdentityContentBytesV1,
354}
355
356impl WordPressAssetFingerprintRepresentationProfile {
357    #[must_use]
358    pub const fn id(self) -> &'static str {
359        match self {
360            Self::IdentityContentBytesV1 => WORDPRESS_ASSET_FINGERPRINT_REPRESENTATION_PROFILE,
361        }
362    }
363}
364
365/// Complete-byte input prepared only after the runtime establishes transport
366/// and committed-evidence eligibility.
367#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
368pub struct WordPressObservedAssetFingerprint {
369    identity: WordPressComponentIdentity,
370    relative_path: String,
371    byte_length: u64,
372    sha256: [u8; 32],
373}
374
375impl WordPressObservedAssetFingerprint {
376    pub fn new(
377        identity: WordPressComponentIdentity,
378        relative_path: impl Into<String>,
379        byte_length: u64,
380        sha256: [u8; 32],
381    ) -> Result<Self, WordPressAssetFingerprintError> {
382        let relative_path = relative_path.into();
383        if !matches!(
384            identity.kind(),
385            WordPressComponentKind::Plugin | WordPressComponentKind::Theme
386        ) || !valid_wordpress_asset_fingerprint_path(&relative_path)
387            || byte_length > MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES
388        {
389            return Err(WordPressAssetFingerprintError::InvalidObservation);
390        }
391        Ok(Self {
392            identity,
393            relative_path: tight_string(relative_path),
394            byte_length,
395            sha256,
396        })
397    }
398
399    #[must_use]
400    pub const fn identity(&self) -> &WordPressComponentIdentity {
401        &self.identity
402    }
403
404    #[must_use]
405    pub fn relative_path(&self) -> &str {
406        &self.relative_path
407    }
408
409    #[must_use]
410    pub const fn byte_length(&self) -> u64 {
411        self.byte_length
412    }
413
414    #[must_use]
415    pub const fn sha256(&self) -> &[u8; 32] {
416        &self.sha256
417    }
418}
419
420/// Three-valued comparison of one observed resource with one release row.
421#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
422pub enum WordPressAssetFingerprintRelation {
423    Match,
424    Mismatch,
425    Unknown,
426}
427
428/// Why a resource/release comparison could not be made.
429#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
430pub enum WordPressAssetFingerprintUnknownReason {
431    MissingReference,
432    ConflictingObservations,
433}
434
435/// Three-valued aggregation for one listed release.
436#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
437pub enum WordPressAssetFingerprintReleaseState {
438    Compatible,
439    Inconsistent,
440    Undetermined,
441}
442
443/// Conditional aggregate over only the releases represented in the catalogue.
444#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
445pub enum WordPressAssetFingerprintAggregateState {
446    SingleCatalogueCandidate,
447    MultipleCatalogueCandidates,
448    ProvisionalCandidates,
449    NoConsistentCatalogueRelease,
450    NoCatalogueByteMatch,
451    Undetermined,
452}
453
454/// One resource path and its comparisons with all listed releases.
455#[derive(Clone, Debug, Eq, PartialEq)]
456pub struct WordPressAssetFingerprintResourceMatch {
457    relative_path: String,
458    distinct_observation_count: usize,
459    informative: bool,
460    releases: Vec<WordPressAssetFingerprintResourceReleaseMatch>,
461}
462
463impl WordPressAssetFingerprintResourceMatch {
464    #[must_use]
465    pub fn relative_path(&self) -> &str {
466        &self.relative_path
467    }
468
469    #[must_use]
470    pub const fn distinct_observation_count(&self) -> usize {
471        self.distinct_observation_count
472    }
473
474    #[must_use]
475    pub const fn informative(&self) -> bool {
476        self.informative
477    }
478
479    #[must_use]
480    pub fn releases(&self) -> &[WordPressAssetFingerprintResourceReleaseMatch] {
481        &self.releases
482    }
483}
484
485/// One resource/release relation with an explicit unknown reason.
486#[derive(Clone, Debug, Eq, PartialEq)]
487pub struct WordPressAssetFingerprintResourceReleaseMatch {
488    release_id: String,
489    relation: WordPressAssetFingerprintRelation,
490    unknown_reason: Option<WordPressAssetFingerprintUnknownReason>,
491}
492
493impl WordPressAssetFingerprintResourceReleaseMatch {
494    #[must_use]
495    pub fn release_id(&self) -> &str {
496        &self.release_id
497    }
498
499    #[must_use]
500    pub const fn relation(&self) -> WordPressAssetFingerprintRelation {
501        self.relation
502    }
503
504    #[must_use]
505    pub const fn unknown_reason(&self) -> Option<WordPressAssetFingerprintUnknownReason> {
506        self.unknown_reason
507    }
508}
509
510/// Aggregate state for one opaque listed release/build.
511#[derive(Clone, Debug, Eq, PartialEq)]
512pub struct WordPressAssetFingerprintReleaseMatch {
513    release_id: String,
514    version: String,
515    build_variant: Option<String>,
516    state: WordPressAssetFingerprintReleaseState,
517}
518
519impl WordPressAssetFingerprintReleaseMatch {
520    #[must_use]
521    pub fn release_id(&self) -> &str {
522        &self.release_id
523    }
524
525    #[must_use]
526    pub fn version(&self) -> &str {
527        &self.version
528    }
529
530    #[must_use]
531    pub fn build_variant(&self) -> Option<&str> {
532        self.build_variant.as_deref()
533    }
534
535    #[must_use]
536    pub const fn state(&self) -> WordPressAssetFingerprintReleaseState {
537        self.state
538    }
539}
540
541/// Pure conditional candidate result for one scoped component identity.
542#[derive(Clone, Debug, Eq, PartialEq)]
543pub struct WordPressAssetFingerprintComponentMatch {
544    identity: WordPressComponentIdentity,
545    catalogue_component_listed: bool,
546    state: WordPressAssetFingerprintAggregateState,
547    candidate_resource_count: usize,
548    selected_resource_count: usize,
549    completely_interpreted_resource_count: usize,
550    informative_resource_count: usize,
551    listed_matrix_complete: bool,
552    resources: Vec<WordPressAssetFingerprintResourceMatch>,
553    releases: Vec<WordPressAssetFingerprintReleaseMatch>,
554}
555
556impl WordPressAssetFingerprintComponentMatch {
557    #[must_use]
558    pub const fn identity(&self) -> &WordPressComponentIdentity {
559        &self.identity
560    }
561
562    #[must_use]
563    pub const fn catalogue_component_listed(&self) -> bool {
564        self.catalogue_component_listed
565    }
566
567    #[must_use]
568    pub const fn state(&self) -> WordPressAssetFingerprintAggregateState {
569        self.state
570    }
571
572    #[must_use]
573    pub const fn candidate_resource_count(&self) -> usize {
574        self.candidate_resource_count
575    }
576
577    #[must_use]
578    pub const fn selected_resource_count(&self) -> usize {
579        self.selected_resource_count
580    }
581
582    #[must_use]
583    pub const fn completely_interpreted_resource_count(&self) -> usize {
584        self.completely_interpreted_resource_count
585    }
586
587    #[must_use]
588    pub const fn omitted_resource_count(&self) -> usize {
589        self.candidate_resource_count
590            .saturating_sub(self.selected_resource_count)
591    }
592
593    #[must_use]
594    pub const fn informative_resource_count(&self) -> usize {
595        self.informative_resource_count
596    }
597
598    #[must_use]
599    pub const fn listed_matrix_complete(&self) -> bool {
600        self.listed_matrix_complete
601    }
602
603    #[must_use]
604    pub fn resources(&self) -> &[WordPressAssetFingerprintResourceMatch] {
605        &self.resources
606    }
607
608    #[must_use]
609    pub fn releases(&self) -> &[WordPressAssetFingerprintReleaseMatch] {
610        &self.releases
611    }
612
613    pub fn compatible_release_ids(&self) -> impl Iterator<Item = &str> {
614        self.releases.iter().filter_map(|release| {
615            (release.state == WordPressAssetFingerprintReleaseState::Compatible)
616                .then_some(release.release_id.as_str())
617        })
618    }
619
620    pub fn undetermined_release_ids(&self) -> impl Iterator<Item = &str> {
621        self.releases.iter().filter_map(|release| {
622            (release.state == WordPressAssetFingerprintReleaseState::Undetermined)
623                .then_some(release.release_id.as_str())
624        })
625    }
626
627    pub fn inconsistent_release_ids(&self) -> impl Iterator<Item = &str> {
628        self.releases.iter().filter_map(|release| {
629            (release.state == WordPressAssetFingerprintReleaseState::Inconsistent)
630                .then_some(release.release_id.as_str())
631        })
632    }
633
634    /// Applies runtime collection coverage that the pure byte matcher cannot
635    /// infer from the observations alone. A selected representation that was
636    /// not completely interpreted must prevent a strong finite-catalogue
637    /// candidate, while preserving every reviewed per-release relation.
638    pub(crate) fn with_collection_coverage(
639        mut self,
640        candidate_resource_count: usize,
641        selected_resource_count: usize,
642        completely_interpreted_resource_count: usize,
643    ) -> Self {
644        self.candidate_resource_count = candidate_resource_count;
645        self.selected_resource_count = selected_resource_count;
646        self.completely_interpreted_resource_count = completely_interpreted_resource_count;
647        if candidate_resource_count != selected_resource_count
648            || selected_resource_count != completely_interpreted_resource_count
649        {
650            self.listed_matrix_complete = false;
651            self.state = match self.state {
652                WordPressAssetFingerprintAggregateState::SingleCatalogueCandidate
653                | WordPressAssetFingerprintAggregateState::MultipleCatalogueCandidates => {
654                    WordPressAssetFingerprintAggregateState::ProvisionalCandidates
655                },
656                WordPressAssetFingerprintAggregateState::NoConsistentCatalogueRelease
657                | WordPressAssetFingerprintAggregateState::NoCatalogueByteMatch => {
658                    WordPressAssetFingerprintAggregateState::Undetermined
659                },
660                state => state,
661            };
662        }
663        self
664    }
665}
666
667#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
668struct ObservedFingerprintValue {
669    byte_length: u64,
670    sha256: [u8; 32],
671}
672
673/// Compares already-qualified complete observations with one component's
674/// finite listed release matrix.
675pub fn match_wordpress_asset_fingerprint_component(
676    catalog: &WordPressAssetFingerprintCatalogImport,
677    identity: &WordPressComponentIdentity,
678    observations: &[WordPressObservedAssetFingerprint],
679) -> Result<WordPressAssetFingerprintComponentMatch, WordPressAssetFingerprintError> {
680    if !matches!(
681        identity.kind(),
682        WordPressComponentKind::Plugin | WordPressComponentKind::Theme
683    ) || observations.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_OBSERVATIONS_PER_COMPONENT
684        || observations
685            .iter()
686            .any(|observation| observation.identity() != identity)
687    {
688        return Err(WordPressAssetFingerprintError::InvalidObservation);
689    }
690
691    let mut observed_by_path = BTreeMap::<String, BTreeSet<ObservedFingerprintValue>>::new();
692    for observation in observations {
693        observed_by_path
694            .entry(observation.relative_path.clone())
695            .or_default()
696            .insert(ObservedFingerprintValue {
697                byte_length: observation.byte_length,
698                sha256: observation.sha256,
699            });
700    }
701    if observed_by_path.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_RESOURCES_PER_COMPONENT {
702        return Err(WordPressAssetFingerprintError::MatchLimitExceeded);
703    }
704
705    let observed_resource_count = observed_by_path.len();
706    let Some(component) = catalog.component(identity) else {
707        return Ok(WordPressAssetFingerprintComponentMatch {
708            identity: identity.clone(),
709            catalogue_component_listed: false,
710            state: WordPressAssetFingerprintAggregateState::Undetermined,
711            candidate_resource_count: observed_resource_count,
712            selected_resource_count: observed_resource_count,
713            completely_interpreted_resource_count: observed_resource_count,
714            informative_resource_count: 0,
715            listed_matrix_complete: false,
716            resources: observed_by_path
717                .into_iter()
718                .map(
719                    |(relative_path, values)| WordPressAssetFingerprintResourceMatch {
720                        relative_path,
721                        distinct_observation_count: values.len(),
722                        informative: false,
723                        releases: Vec::new(),
724                    },
725                )
726                .collect(),
727            releases: Vec::new(),
728        });
729    };
730
731    let mut resources = Vec::with_capacity(observed_by_path.len());
732    for (relative_path, observations) in observed_by_path {
733        let reference_values = component
734            .releases
735            .iter()
736            .filter_map(|release| release.file(&relative_path))
737            .map(|file| ObservedFingerprintValue {
738                byte_length: file.byte_length,
739                sha256: file.sha256,
740            })
741            .collect::<BTreeSet<_>>();
742        let informative = reference_values.len() > 1;
743        let releases = component
744            .releases
745            .iter()
746            .map(|release| {
747                let (relation, unknown_reason) = match release.file(&relative_path) {
748                    None => (
749                        WordPressAssetFingerprintRelation::Unknown,
750                        Some(WordPressAssetFingerprintUnknownReason::MissingReference),
751                    ),
752                    Some(_) if observations.len() != 1 => (
753                        WordPressAssetFingerprintRelation::Unknown,
754                        Some(WordPressAssetFingerprintUnknownReason::ConflictingObservations),
755                    ),
756                    Some(reference) => {
757                        let observed = observations
758                            .iter()
759                            .next()
760                            .expect("one checked observed fingerprint remains");
761                        let relation = if observed.byte_length == reference.byte_length
762                            && observed.sha256 == reference.sha256
763                        {
764                            WordPressAssetFingerprintRelation::Match
765                        } else {
766                            WordPressAssetFingerprintRelation::Mismatch
767                        };
768                        (relation, None)
769                    },
770                };
771                WordPressAssetFingerprintResourceReleaseMatch {
772                    release_id: release.release_id.clone(),
773                    relation,
774                    unknown_reason,
775                }
776            })
777            .collect();
778        resources.push(WordPressAssetFingerprintResourceMatch {
779            relative_path,
780            distinct_observation_count: observations.len(),
781            informative,
782            releases,
783        });
784    }
785
786    let releases = component
787        .releases
788        .iter()
789        .enumerate()
790        .map(|(release_index, release)| {
791            let relations = resources
792                .iter()
793                .map(|resource| resource.releases[release_index].relation)
794                .collect::<Vec<_>>();
795            let state = if relations.contains(&WordPressAssetFingerprintRelation::Mismatch) {
796                WordPressAssetFingerprintReleaseState::Inconsistent
797            } else if !relations.is_empty()
798                && relations
799                    .iter()
800                    .all(|relation| *relation == WordPressAssetFingerprintRelation::Match)
801            {
802                WordPressAssetFingerprintReleaseState::Compatible
803            } else {
804                WordPressAssetFingerprintReleaseState::Undetermined
805            };
806            WordPressAssetFingerprintReleaseMatch {
807                release_id: release.release_id.clone(),
808                version: release.version.clone(),
809                build_variant: release.build_variant.clone(),
810                state,
811            }
812        })
813        .collect::<Vec<_>>();
814
815    let informative_resource_count = resources
816        .iter()
817        .filter(|resource| resource.informative)
818        .count();
819    let listed_matrix_complete = !resources.is_empty()
820        && resources.iter().all(|resource| {
821            resource
822                .releases
823                .iter()
824                .all(|release| release.relation != WordPressAssetFingerprintRelation::Unknown)
825        });
826    let compatible = releases
827        .iter()
828        .filter(|release| release.state == WordPressAssetFingerprintReleaseState::Compatible)
829        .count();
830    let undetermined = releases
831        .iter()
832        .filter(|release| release.state == WordPressAssetFingerprintReleaseState::Undetermined)
833        .count();
834    let any_match = resources.iter().any(|resource| {
835        resource
836            .releases
837            .iter()
838            .any(|release| release.relation == WordPressAssetFingerprintRelation::Match)
839    });
840    let state = if compatible > 0 {
841        if listed_matrix_complete && informative_resource_count >= 2 && undetermined == 0 {
842            if compatible == 1 {
843                WordPressAssetFingerprintAggregateState::SingleCatalogueCandidate
844            } else {
845                WordPressAssetFingerprintAggregateState::MultipleCatalogueCandidates
846            }
847        } else {
848            WordPressAssetFingerprintAggregateState::ProvisionalCandidates
849        }
850    } else if undetermined > 0 {
851        WordPressAssetFingerprintAggregateState::Undetermined
852    } else if any_match {
853        WordPressAssetFingerprintAggregateState::NoConsistentCatalogueRelease
854    } else if !resources.is_empty() {
855        WordPressAssetFingerprintAggregateState::NoCatalogueByteMatch
856    } else {
857        WordPressAssetFingerprintAggregateState::Undetermined
858    };
859
860    Ok(WordPressAssetFingerprintComponentMatch {
861        identity: identity.clone(),
862        catalogue_component_listed: true,
863        state,
864        candidate_resource_count: observed_resource_count,
865        selected_resource_count: observed_resource_count,
866        completely_interpreted_resource_count: observed_resource_count,
867        informative_resource_count,
868        listed_matrix_complete,
869        resources,
870        releases,
871    })
872}
873
874/// Parses one complete strict local catalogue and prepares deterministic lookup
875/// state. The exact digest identifies the supplied bytes; the semantic digest
876/// ignores only ordering and JSON layout.
877pub fn parse_wordpress_asset_fingerprint_catalog(
878    bytes: &[u8],
879) -> Result<WordPressAssetFingerprintCatalogImport, WordPressAssetFingerprintError> {
880    let value = parse_bounded_json(
881        bytes,
882        MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_BYTES,
883        MAX_CATALOG_JSON_NODES,
884        MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_FILES,
885        WordPressReviewError::CatalogTooLarge,
886    )
887    .map_err(map_bounded_json_error)?;
888    if value.get("schema").and_then(serde_json::Value::as_str)
889        != Some(WORDPRESS_ASSET_FINGERPRINT_CATALOG_SCHEMA)
890    {
891        return Err(WordPressAssetFingerprintError::UnsupportedSchema);
892    }
893    let wire: CatalogWire = serde_json::from_value(value)
894        .map_err(|_| WordPressAssetFingerprintError::InvalidCatalog)?;
895    if wire.schema != WORDPRESS_ASSET_FINGERPRINT_CATALOG_SCHEMA {
896        return Err(WordPressAssetFingerprintError::UnsupportedSchema);
897    }
898
899    let mut catalog = validate_catalog_metadata(wire.catalog)?;
900    catalog
901        .provenance
902        .notices
903        .sort_by(|left, right| left.id.cmp(&right.id));
904    let notice_ids = catalog
905        .provenance
906        .notices
907        .iter()
908        .map(|notice| notice.id.as_str())
909        .collect::<BTreeSet<_>>();
910    if notice_ids.len() != catalog.provenance.notices.len() {
911        return Err(WordPressAssetFingerprintError::DuplicateIdentity);
912    }
913
914    if wire.components.is_empty()
915        || wire.components.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_COMPONENTS
916    {
917        return Err(WordPressAssetFingerprintError::InvalidCatalog);
918    }
919    let mut component_identities = BTreeSet::new();
920    let mut components = Vec::with_capacity(wire.components.len());
921    let mut release_count = 0_usize;
922    let mut file_count = 0_usize;
923    for component in wire.components {
924        let kind = component.kind.into();
925        let slug = tight_string(component.slug);
926        let identity = WordPressComponentIdentity::new(kind, slug)
927            .map_err(|_| WordPressAssetFingerprintError::InvalidCatalog)?;
928        if !component_identities.insert(identity.clone()) {
929            return Err(WordPressAssetFingerprintError::DuplicateIdentity);
930        }
931        if component.releases.is_empty()
932            || component.releases.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_RELEASES_PER_COMPONENT
933        {
934            return Err(WordPressAssetFingerprintError::InvalidCatalog);
935        }
936        release_count = release_count
937            .checked_add(component.releases.len())
938            .ok_or(WordPressAssetFingerprintError::StructuralLimitExceeded)?;
939        let mut release_ids = BTreeSet::new();
940        let mut releases = Vec::with_capacity(component.releases.len());
941        for release in component.releases {
942            if !valid_identifier(&release.release_id)
943                || !valid_version_label(&release.version)
944                || release
945                    .build_variant
946                    .as_deref()
947                    .is_some_and(|value| !valid_identifier(value))
948                || release.files.is_empty()
949                || release.files.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_FILES_PER_RELEASE
950            {
951                return Err(WordPressAssetFingerprintError::InvalidCatalog);
952            }
953            if !release_ids.insert(release.release_id.clone()) {
954                return Err(WordPressAssetFingerprintError::DuplicateIdentity);
955            }
956            file_count = file_count
957                .checked_add(release.files.len())
958                .filter(|count| *count <= MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_FILES)
959                .ok_or(WordPressAssetFingerprintError::StructuralLimitExceeded)?;
960            let source = validate_release_source(release.source, &notice_ids)?;
961            let mut paths = BTreeSet::new();
962            let mut files = Vec::with_capacity(release.files.len());
963            for file in release.files {
964                if !valid_wordpress_asset_fingerprint_path(&file.path)
965                    || file.byte_length > MAX_WORDPRESS_ASSET_FINGERPRINT_ASSET_BYTES
966                    || file.representation_profile
967                        != RepresentationProfileWire::IdentityContentBytesV1
968                {
969                    return Err(WordPressAssetFingerprintError::InvalidCatalog);
970                }
971                if !paths.insert(file.path.clone()) {
972                    return Err(WordPressAssetFingerprintError::DuplicateIdentity);
973                }
974                files.push(WordPressAssetFingerprintFile {
975                    path: tight_string(file.path),
976                    byte_length: file.byte_length,
977                    sha256: parse_sha256(&file.sha256)?,
978                    representation_profile:
979                        WordPressAssetFingerprintRepresentationProfile::IdentityContentBytesV1,
980                });
981            }
982            files.sort_by(|left, right| left.path.cmp(&right.path));
983            files.shrink_to_fit();
984            releases.push(WordPressAssetFingerprintRelease {
985                release_id: tight_string(release.release_id),
986                version: tight_string(release.version),
987                build_variant: release.build_variant.map(tight_string),
988                source,
989                files,
990            });
991        }
992        releases.sort_by(|left, right| left.release_id.cmp(&right.release_id));
993        releases.shrink_to_fit();
994        components.push(WordPressAssetFingerprintComponent { identity, releases });
995    }
996    components.sort_by(|left, right| left.identity.cmp(&right.identity));
997    components.shrink_to_fit();
998    catalog.provenance.notices.shrink_to_fit();
999
1000    let semantic_sha256 = semantic_digest(&catalog, &components);
1001    let byte_length =
1002        u64::try_from(bytes.len()).map_err(|_| WordPressAssetFingerprintError::InputTooLarge)?;
1003    let mut import = WordPressAssetFingerprintCatalogImport {
1004        byte_length,
1005        sha256: Sha256::digest(bytes).into(),
1006        semantic_sha256,
1007        retained_bytes: 0,
1008        release_count,
1009        file_count,
1010        catalog,
1011        components,
1012    };
1013    import.retained_bytes = retained_bytes(&import)?;
1014    if import.retained_bytes > MAX_WORDPRESS_ASSET_FINGERPRINT_RETAINED_BYTES {
1015        return Err(WordPressAssetFingerprintError::RetainedDataTooLarge);
1016    }
1017    Ok(import)
1018}
1019
1020fn map_bounded_json_error(error: WordPressReviewError) -> WordPressAssetFingerprintError {
1021    match error {
1022        WordPressReviewError::EmptyInput => WordPressAssetFingerprintError::EmptyInput,
1023        WordPressReviewError::CatalogTooLarge => WordPressAssetFingerprintError::InputTooLarge,
1024        WordPressReviewError::DuplicateKey => WordPressAssetFingerprintError::DuplicateKey,
1025        WordPressReviewError::JsonLimitExceeded => {
1026            WordPressAssetFingerprintError::StructuralLimitExceeded
1027        },
1028        WordPressReviewError::MalformedJson => WordPressAssetFingerprintError::MalformedJson,
1029        _ => WordPressAssetFingerprintError::InvalidCatalog,
1030    }
1031}
1032
1033fn validate_catalog_metadata(
1034    wire: CatalogMetadataWire,
1035) -> Result<WordPressAssetFingerprintCatalogMetadata, WordPressAssetFingerprintError> {
1036    if !valid_identifier(&wire.id)
1037        || !valid_identifier(&wire.revision)
1038        || !valid_identifier(&wire.source_namespace)
1039        || wire.provenance.notices.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICES
1040    {
1041        return Err(WordPressAssetFingerprintError::InvalidCatalog);
1042    }
1043    validate_reference(&wire.provenance.reference)?;
1044    if !valid_identifier(&wire.provenance.revision) {
1045        return Err(WordPressAssetFingerprintError::InvalidCatalog);
1046    }
1047    let mut notices = Vec::with_capacity(wire.provenance.notices.len());
1048    for notice in wire.provenance.notices {
1049        if !valid_identifier(&notice.id)
1050            || !valid_bounded_text(&notice.party, MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICE_BYTES)
1051            || !valid_bounded_text(&notice.notice, MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICE_BYTES)
1052            || !valid_bounded_text(
1053                &notice.license,
1054                MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICE_BYTES,
1055            )
1056        {
1057            return Err(WordPressAssetFingerprintError::InvalidCatalog);
1058        }
1059        validate_reference(&notice.license_reference)?;
1060        notices.push(WordPressAssetFingerprintNotice {
1061            id: tight_string(notice.id),
1062            party: tight_string(notice.party),
1063            notice: tight_string(notice.notice),
1064            license: tight_string(notice.license),
1065            license_reference: tight_string(notice.license_reference),
1066        });
1067    }
1068    Ok(WordPressAssetFingerprintCatalogMetadata {
1069        id: tight_string(wire.id),
1070        revision: tight_string(wire.revision),
1071        source_namespace: tight_string(wire.source_namespace),
1072        provenance: WordPressAssetFingerprintProvenance {
1073            reference: tight_string(wire.provenance.reference),
1074            revision: tight_string(wire.provenance.revision),
1075            notices,
1076        },
1077    })
1078}
1079
1080fn validate_release_source(
1081    wire: ReleaseSourceWire,
1082    catalogue_notice_ids: &BTreeSet<&str>,
1083) -> Result<WordPressAssetFingerprintReleaseSource, WordPressAssetFingerprintError> {
1084    validate_reference(&wire.reference)?;
1085    if !valid_identifier(&wire.revision)
1086        || wire.notice_ids.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICES
1087    {
1088        return Err(WordPressAssetFingerprintError::InvalidCatalog);
1089    }
1090    let mut notice_ids = BTreeSet::new();
1091    for notice_id in wire.notice_ids {
1092        if !valid_identifier(&notice_id)
1093            || !catalogue_notice_ids.contains(notice_id.as_str())
1094            || !notice_ids.insert(tight_string(notice_id))
1095        {
1096            return Err(WordPressAssetFingerprintError::InvalidCatalog);
1097        }
1098    }
1099    Ok(WordPressAssetFingerprintReleaseSource {
1100        reference: tight_string(wire.reference),
1101        revision: tight_string(wire.revision),
1102        notice_ids: notice_ids.into_iter().collect(),
1103    })
1104}
1105
1106fn valid_identifier(value: &str) -> bool {
1107    !value.is_empty()
1108        && value.len() <= MAX_WORDPRESS_IDENTIFIER_BYTES
1109        && value.bytes().all(|byte| {
1110            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'@' | b'-')
1111        })
1112}
1113
1114fn valid_version_label(value: &str) -> bool {
1115    !value.is_empty()
1116        && value.len() <= MAX_WORDPRESS_VERSION_BYTES
1117        && value.trim() == value
1118        && !value.chars().any(char::is_control)
1119}
1120
1121fn valid_bounded_text(value: &str, maximum: usize) -> bool {
1122    !value.is_empty()
1123        && value.len() <= maximum
1124        && value.trim() == value
1125        && !value.chars().any(char::is_control)
1126}
1127
1128fn validate_reference(value: &str) -> Result<(), WordPressAssetFingerprintError> {
1129    if value.is_empty()
1130        || value.len() > MAX_WORDPRESS_REFERENCE_BYTES
1131        || value.trim() != value
1132        || value.chars().any(char::is_control)
1133    {
1134        return Err(WordPressAssetFingerprintError::InvalidCatalog);
1135    }
1136    let parsed = Url::parse(value).map_err(|_| WordPressAssetFingerprintError::InvalidCatalog)?;
1137    if !matches!(parsed.scheme(), "http" | "https")
1138        || !parsed.has_host()
1139        || !parsed.username().is_empty()
1140        || parsed.password().is_some()
1141        || parsed.query().is_some()
1142    {
1143        return Err(WordPressAssetFingerprintError::InvalidCatalog);
1144    }
1145    Ok(())
1146}
1147
1148pub(crate) fn valid_wordpress_asset_fingerprint_path(path: &str) -> bool {
1149    if path.is_empty()
1150        || path.len() > MAX_WORDPRESS_ASSET_FINGERPRINT_PATH_BYTES
1151        || !path.is_ascii()
1152        || path.starts_with('/')
1153        || path.ends_with('/')
1154        || path.contains(['\\', '%', '?', '#', ':'])
1155        || path
1156            .bytes()
1157            .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
1158        || !(path.ends_with(".js") || path.ends_with(".css"))
1159    {
1160        return false;
1161    }
1162    path.split('/')
1163        .all(|segment| !segment.is_empty() && !matches!(segment, "." | ".."))
1164}
1165
1166fn parse_sha256(value: &str) -> Result<[u8; 32], WordPressAssetFingerprintError> {
1167    if value.len() != 64
1168        || !value
1169            .bytes()
1170            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1171    {
1172        return Err(WordPressAssetFingerprintError::InvalidCatalog);
1173    }
1174    let mut digest = [0_u8; 32];
1175    for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
1176        digest[index] = hex_nibble(pair[0])
1177            .and_then(|high| hex_nibble(pair[1]).map(|low| (high << 4) | low))
1178            .ok_or(WordPressAssetFingerprintError::InvalidCatalog)?;
1179    }
1180    Ok(digest)
1181}
1182
1183fn hex_nibble(value: u8) -> Option<u8> {
1184    match value {
1185        b'0'..=b'9' => Some(value - b'0'),
1186        b'a'..=b'f' => Some(value - b'a' + 10),
1187        _ => None,
1188    }
1189}
1190
1191fn tight_string(value: String) -> String {
1192    value.into_boxed_str().into_string()
1193}
1194
1195fn semantic_digest(
1196    catalog: &WordPressAssetFingerprintCatalogMetadata,
1197    components: &[WordPressAssetFingerprintComponent],
1198) -> [u8; 32] {
1199    let mut digest = Sha256::new();
1200    digest.update(b"termivar.wordpress-asset-fingerprint-catalog.semantic/v1\0");
1201    for value in [
1202        WORDPRESS_ASSET_FINGERPRINT_CATALOG_SCHEMA,
1203        catalog.id.as_str(),
1204        catalog.revision.as_str(),
1205        catalog.source_namespace.as_str(),
1206        catalog.provenance.reference.as_str(),
1207        catalog.provenance.revision.as_str(),
1208    ] {
1209        frame(&mut digest, value.as_bytes());
1210    }
1211    frame_usize(&mut digest, catalog.provenance.notices.len());
1212    for notice in &catalog.provenance.notices {
1213        for value in [
1214            notice.id.as_str(),
1215            notice.party.as_str(),
1216            notice.notice.as_str(),
1217            notice.license.as_str(),
1218            notice.license_reference.as_str(),
1219        ] {
1220            frame(&mut digest, value.as_bytes());
1221        }
1222    }
1223    frame_usize(&mut digest, components.len());
1224    for component in components {
1225        digest.update([match component.identity.kind() {
1226            WordPressComponentKind::Plugin => 1,
1227            WordPressComponentKind::Theme => 2,
1228            WordPressComponentKind::Core => 0,
1229        }]);
1230        frame(&mut digest, component.identity.slug().as_bytes());
1231        frame_usize(&mut digest, component.releases.len());
1232        for release in &component.releases {
1233            frame(&mut digest, release.release_id.as_bytes());
1234            frame(&mut digest, release.version.as_bytes());
1235            frame_optional(&mut digest, release.build_variant.as_deref());
1236            frame(&mut digest, release.source.reference.as_bytes());
1237            frame(&mut digest, release.source.revision.as_bytes());
1238            frame_usize(&mut digest, release.source.notice_ids.len());
1239            for notice_id in &release.source.notice_ids {
1240                frame(&mut digest, notice_id.as_bytes());
1241            }
1242            frame_usize(&mut digest, release.files.len());
1243            for file in &release.files {
1244                frame(&mut digest, file.path.as_bytes());
1245                digest.update(file.byte_length.to_be_bytes());
1246                digest.update(file.sha256);
1247                frame(&mut digest, file.representation_profile.id().as_bytes());
1248            }
1249        }
1250    }
1251    digest.finalize().into()
1252}
1253
1254fn frame(digest: &mut Sha256, value: &[u8]) {
1255    digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
1256    digest.update(value);
1257}
1258
1259fn frame_optional(digest: &mut Sha256, value: Option<&str>) {
1260    match value {
1261        Some(value) => {
1262            digest.update([1]);
1263            frame(digest, value.as_bytes());
1264        },
1265        None => digest.update([0]),
1266    }
1267}
1268
1269fn frame_usize(digest: &mut Sha256, value: usize) {
1270    digest.update(u64::try_from(value).unwrap_or(u64::MAX).to_be_bytes());
1271}
1272
1273fn retained_bytes(
1274    import: &WordPressAssetFingerprintCatalogImport,
1275) -> Result<usize, WordPressAssetFingerprintError> {
1276    let mut total = size_of::<WordPressAssetFingerprintCatalogImport>();
1277    for value in [
1278        import.catalog.id.as_str(),
1279        import.catalog.revision.as_str(),
1280        import.catalog.source_namespace.as_str(),
1281        import.catalog.provenance.reference.as_str(),
1282        import.catalog.provenance.revision.as_str(),
1283    ] {
1284        charge_string(&mut total, value)?;
1285    }
1286    charge_vec::<WordPressAssetFingerprintNotice>(
1287        &mut total,
1288        import.catalog.provenance.notices.capacity(),
1289    )?;
1290    for notice in &import.catalog.provenance.notices {
1291        for value in [
1292            notice.id.as_str(),
1293            notice.party.as_str(),
1294            notice.notice.as_str(),
1295            notice.license.as_str(),
1296            notice.license_reference.as_str(),
1297        ] {
1298            charge_string(&mut total, value)?;
1299        }
1300    }
1301    charge_vec::<WordPressAssetFingerprintComponent>(&mut total, import.components.capacity())?;
1302    for component in &import.components {
1303        charge_string(&mut total, component.identity.slug())?;
1304        charge_vec::<WordPressAssetFingerprintRelease>(&mut total, component.releases.capacity())?;
1305        for release in &component.releases {
1306            for value in [
1307                release.release_id.as_str(),
1308                release.version.as_str(),
1309                release.source.reference.as_str(),
1310                release.source.revision.as_str(),
1311            ] {
1312                charge_string(&mut total, value)?;
1313            }
1314            if let Some(build_variant) = &release.build_variant {
1315                charge_string(&mut total, build_variant)?;
1316            }
1317            charge_vec::<String>(&mut total, release.source.notice_ids.capacity())?;
1318            for notice_id in &release.source.notice_ids {
1319                charge_string(&mut total, notice_id)?;
1320            }
1321            charge_vec::<WordPressAssetFingerprintFile>(&mut total, release.files.capacity())?;
1322            for file in &release.files {
1323                charge_string(&mut total, &file.path)?;
1324            }
1325        }
1326    }
1327    Ok(total)
1328}
1329
1330fn charge_string(total: &mut usize, value: &str) -> Result<(), WordPressAssetFingerprintError> {
1331    charge(total, value.len())?;
1332    charge(total, CONSERVATIVE_ALLOCATION_OVERHEAD)
1333}
1334
1335fn charge_vec<T>(total: &mut usize, capacity: usize) -> Result<(), WordPressAssetFingerprintError> {
1336    let bytes = capacity
1337        .checked_mul(size_of::<T>())
1338        .ok_or(WordPressAssetFingerprintError::RetainedDataTooLarge)?;
1339    charge(total, bytes)?;
1340    charge(total, CONSERVATIVE_ALLOCATION_OVERHEAD)
1341}
1342
1343fn charge(total: &mut usize, bytes: usize) -> Result<(), WordPressAssetFingerprintError> {
1344    *total = total
1345        .checked_add(bytes)
1346        .ok_or(WordPressAssetFingerprintError::RetainedDataTooLarge)?;
1347    if *total > MAX_WORDPRESS_ASSET_FINGERPRINT_RETAINED_BYTES {
1348        return Err(WordPressAssetFingerprintError::RetainedDataTooLarge);
1349    }
1350    Ok(())
1351}
1352
1353#[derive(Deserialize)]
1354#[serde(deny_unknown_fields)]
1355struct CatalogWire {
1356    schema: String,
1357    catalog: CatalogMetadataWire,
1358    components: Vec<ComponentWire>,
1359}
1360
1361#[derive(Deserialize)]
1362#[serde(deny_unknown_fields)]
1363struct CatalogMetadataWire {
1364    id: String,
1365    revision: String,
1366    source_namespace: String,
1367    provenance: ProvenanceWire,
1368}
1369
1370#[derive(Deserialize)]
1371#[serde(deny_unknown_fields)]
1372struct ProvenanceWire {
1373    reference: String,
1374    revision: String,
1375    notices: Vec<NoticeWire>,
1376}
1377
1378#[derive(Deserialize)]
1379#[serde(deny_unknown_fields)]
1380struct NoticeWire {
1381    id: String,
1382    party: String,
1383    notice: String,
1384    license: String,
1385    license_reference: String,
1386}
1387
1388#[derive(Deserialize)]
1389#[serde(deny_unknown_fields)]
1390struct ComponentWire {
1391    kind: ComponentKindWire,
1392    slug: String,
1393    releases: Vec<ReleaseWire>,
1394}
1395
1396#[derive(Clone, Copy, Deserialize)]
1397#[serde(rename_all = "snake_case")]
1398enum ComponentKindWire {
1399    Plugin,
1400    Theme,
1401}
1402
1403impl From<ComponentKindWire> for WordPressComponentKind {
1404    fn from(value: ComponentKindWire) -> Self {
1405        match value {
1406            ComponentKindWire::Plugin => Self::Plugin,
1407            ComponentKindWire::Theme => Self::Theme,
1408        }
1409    }
1410}
1411
1412#[derive(Deserialize)]
1413#[serde(deny_unknown_fields)]
1414struct ReleaseWire {
1415    release_id: String,
1416    version: String,
1417    #[serde(default)]
1418    build_variant: Option<String>,
1419    source: ReleaseSourceWire,
1420    files: Vec<FileWire>,
1421}
1422
1423#[derive(Deserialize)]
1424#[serde(deny_unknown_fields)]
1425struct ReleaseSourceWire {
1426    reference: String,
1427    revision: String,
1428    notice_ids: Vec<String>,
1429}
1430
1431#[derive(Deserialize)]
1432#[serde(deny_unknown_fields)]
1433struct FileWire {
1434    path: String,
1435    byte_length: u64,
1436    sha256: String,
1437    representation_profile: RepresentationProfileWire,
1438}
1439
1440#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
1441enum RepresentationProfileWire {
1442    #[serde(rename = "identity-content-bytes/v1")]
1443    IdentityContentBytesV1,
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448    use serde_json::{json, Value};
1449
1450    use super::*;
1451
1452    const SHA_JS_AB: &str = "1111111111111111111111111111111111111111111111111111111111111111";
1453    const SHA_JS_C: &str = "2222222222222222222222222222222222222222222222222222222222222222";
1454    const SHA_CSS_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1455    const SHA_CSS_BC: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
1456    const SHA_OTHER: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
1457
1458    fn file(path: &str, byte_length: u64, sha256: &str) -> Value {
1459        json!({
1460            "path": path,
1461            "byte_length": byte_length,
1462            "sha256": sha256,
1463            "representation_profile": "identity-content-bytes/v1"
1464        })
1465    }
1466
1467    fn release(id: &str, version: &str, files: Vec<Value>) -> Value {
1468        json!({
1469            "release_id": id,
1470            "version": version,
1471            "source": {
1472                "reference": format!("https://example.invalid/releases/{id}"),
1473                "revision": id,
1474                "notice_ids": ["notice-main"]
1475            },
1476            "files": files
1477        })
1478    }
1479
1480    fn literal_catalogue() -> Value {
1481        json!({
1482            "schema": "security.wordpress-asset-fingerprint-catalog/v1",
1483            "catalog": {
1484                "id": "task-owned-reference",
1485                "revision": "matrix-1",
1486                "source_namespace": "termivar-test",
1487                "provenance": {
1488                    "reference": "https://example.invalid/source",
1489                    "revision": "source-1",
1490                    "notices": [{
1491                        "id": "notice-main",
1492                        "party": "Termivar test authors",
1493                        "notice": "Original harmless reference bytes.",
1494                        "license": "test-only",
1495                        "license_reference": "https://example.invalid/license"
1496                    }]
1497                }
1498            },
1499            "components": [{
1500                "kind": "plugin",
1501                "slug": "matrix-plugin",
1502                "releases": [
1503                    release("release-a", "1.0", vec![
1504                        file("assets/app.js", 11, SHA_JS_AB),
1505                        file("assets/app.css", 21, SHA_CSS_A)
1506                    ]),
1507                    release("release-b", "1.0.0", vec![
1508                        file("assets/app.js", 11, SHA_JS_AB),
1509                        file("assets/app.css", 22, SHA_CSS_BC)
1510                    ]),
1511                    release("release-c", "2.0", vec![
1512                        file("assets/app.js", 12, SHA_JS_C),
1513                        file("assets/app.css", 22, SHA_CSS_BC)
1514                    ])
1515                ]
1516            }]
1517        })
1518    }
1519
1520    fn parse_fixture() -> WordPressAssetFingerprintCatalogImport {
1521        parse_wordpress_asset_fingerprint_catalog(
1522            serde_json::to_vec(&literal_catalogue()).unwrap().as_slice(),
1523        )
1524        .unwrap()
1525    }
1526
1527    fn identity() -> WordPressComponentIdentity {
1528        WordPressComponentIdentity::new(WordPressComponentKind::Plugin, "matrix-plugin").unwrap()
1529    }
1530
1531    fn observed(path: &str, byte_length: u64, sha256: &str) -> WordPressObservedAssetFingerprint {
1532        WordPressObservedAssetFingerprint::new(
1533            identity(),
1534            path,
1535            byte_length,
1536            parse_sha256(sha256).unwrap(),
1537        )
1538        .unwrap()
1539    }
1540
1541    fn release_states(
1542        matched: &WordPressAssetFingerprintComponentMatch,
1543    ) -> Vec<(&str, WordPressAssetFingerprintReleaseState)> {
1544        matched
1545            .releases()
1546            .iter()
1547            .map(|release| (release.release_id(), release.state()))
1548            .collect()
1549    }
1550
1551    #[test]
1552    fn strict_catalogue_retains_raw_release_identity_and_finite_metadata() {
1553        let encoded = serde_json::to_vec(&literal_catalogue()).unwrap();
1554        let catalog = parse_wordpress_asset_fingerprint_catalog(&encoded).unwrap();
1555        assert_eq!(catalog.byte_length(), encoded.len() as u64);
1556        assert_eq!(catalog.components().len(), 1);
1557        assert_eq!(catalog.release_count(), 3);
1558        assert_eq!(catalog.file_count(), 6);
1559        assert!(catalog.retained_bytes() <= MAX_WORDPRESS_ASSET_FINGERPRINT_RETAINED_BYTES);
1560        let releases = catalog.component(&identity()).unwrap().releases();
1561        assert_eq!(releases[0].version(), "1.0");
1562        assert_eq!(releases[1].version(), "1.0.0");
1563        assert_ne!(releases[0].release_id(), releases[1].release_id());
1564        assert_eq!(
1565            releases[0].files()[0].representation_profile().id(),
1566            WORDPRESS_ASSET_FINGERPRINT_REPRESENTATION_PROFILE
1567        );
1568    }
1569
1570    #[test]
1571    fn semantic_digest_ignores_only_layout_and_set_order() {
1572        let original_value = literal_catalogue();
1573        let original_bytes = serde_json::to_vec(&original_value).unwrap();
1574        let mut reordered = original_value;
1575        reordered["components"][0]["releases"]
1576            .as_array_mut()
1577            .unwrap()
1578            .reverse();
1579        for release in reordered["components"][0]["releases"]
1580            .as_array_mut()
1581            .unwrap()
1582        {
1583            release["files"].as_array_mut().unwrap().reverse();
1584        }
1585        let reordered_bytes = serde_json::to_vec_pretty(&reordered).unwrap();
1586        let original = parse_wordpress_asset_fingerprint_catalog(&original_bytes).unwrap();
1587        let reordered = parse_wordpress_asset_fingerprint_catalog(&reordered_bytes).unwrap();
1588        assert_ne!(original.sha256(), reordered.sha256());
1589        assert_eq!(original.semantic_sha256(), reordered.semantic_sha256());
1590    }
1591
1592    #[test]
1593    fn two_independently_specified_informative_paths_select_one_listed_release() {
1594        let catalog = parse_fixture();
1595        let matched = match_wordpress_asset_fingerprint_component(
1596            &catalog,
1597            &identity(),
1598            &[
1599                observed("assets/app.js", 11, SHA_JS_AB),
1600                observed("assets/app.css", 22, SHA_CSS_BC),
1601            ],
1602        )
1603        .unwrap();
1604        assert_eq!(
1605            matched.state(),
1606            WordPressAssetFingerprintAggregateState::SingleCatalogueCandidate
1607        );
1608        assert_eq!(matched.informative_resource_count(), 2);
1609        assert!(matched.listed_matrix_complete());
1610        assert_eq!(
1611            matched.compatible_release_ids().collect::<Vec<_>>(),
1612            ["release-b"]
1613        );
1614        assert_eq!(
1615            release_states(&matched),
1616            [
1617                (
1618                    "release-a",
1619                    WordPressAssetFingerprintReleaseState::Inconsistent
1620                ),
1621                (
1622                    "release-b",
1623                    WordPressAssetFingerprintReleaseState::Compatible
1624                ),
1625                (
1626                    "release-c",
1627                    WordPressAssetFingerprintReleaseState::Inconsistent
1628                ),
1629            ]
1630        );
1631    }
1632
1633    #[test]
1634    fn incomplete_runtime_observation_coverage_downgrades_strong_candidate() {
1635        let catalog = parse_fixture();
1636        let matched = match_wordpress_asset_fingerprint_component(
1637            &catalog,
1638            &identity(),
1639            &[
1640                observed("assets/app.js", 11, SHA_JS_AB),
1641                observed("assets/app.css", 22, SHA_CSS_BC),
1642            ],
1643        )
1644        .unwrap();
1645        let original_release_states = release_states(&matched);
1646        let covered = matched.clone().with_collection_coverage(3, 2, 2);
1647
1648        assert_eq!(
1649            covered.state(),
1650            WordPressAssetFingerprintAggregateState::ProvisionalCandidates
1651        );
1652        assert!(!covered.listed_matrix_complete());
1653        assert_eq!(covered.candidate_resource_count(), 3);
1654        assert_eq!(covered.selected_resource_count(), 2);
1655        assert_eq!(covered.completely_interpreted_resource_count(), 2);
1656        assert_eq!(covered.omitted_resource_count(), 1);
1657        assert_eq!(release_states(&covered), original_release_states);
1658        assert_eq!(
1659            covered.compatible_release_ids().collect::<Vec<_>>(),
1660            ["release-b"]
1661        );
1662    }
1663
1664    #[test]
1665    fn incomplete_runtime_coverage_cannot_preserve_a_strong_negative_summary() {
1666        let catalog = parse_fixture();
1667        let mixed = match_wordpress_asset_fingerprint_component(
1668            &catalog,
1669            &identity(),
1670            &[
1671                observed("assets/app.js", 11, SHA_JS_AB),
1672                observed("assets/app.css", 23, SHA_OTHER),
1673            ],
1674        )
1675        .unwrap();
1676        assert_eq!(
1677            mixed.state(),
1678            WordPressAssetFingerprintAggregateState::NoConsistentCatalogueRelease
1679        );
1680        assert_eq!(
1681            mixed.with_collection_coverage(2, 2, 1).state(),
1682            WordPressAssetFingerprintAggregateState::Undetermined
1683        );
1684
1685        let no_match = match_wordpress_asset_fingerprint_component(
1686            &catalog,
1687            &identity(),
1688            &[
1689                observed("assets/app.js", 13, SHA_OTHER),
1690                observed("assets/app.css", 23, SHA_OTHER),
1691            ],
1692        )
1693        .unwrap();
1694        assert_eq!(
1695            no_match.state(),
1696            WordPressAssetFingerprintAggregateState::NoCatalogueByteMatch
1697        );
1698        assert_eq!(
1699            no_match.with_collection_coverage(3, 2, 2).state(),
1700            WordPressAssetFingerprintAggregateState::Undetermined
1701        );
1702    }
1703
1704    #[test]
1705    fn one_file_is_provisional_even_when_its_candidate_set_is_exact_for_that_path() {
1706        let catalog = parse_fixture();
1707        let matched = match_wordpress_asset_fingerprint_component(
1708            &catalog,
1709            &identity(),
1710            &[observed("assets/app.js", 11, SHA_JS_AB)],
1711        )
1712        .unwrap();
1713        assert_eq!(
1714            matched.state(),
1715            WordPressAssetFingerprintAggregateState::ProvisionalCandidates
1716        );
1717        assert_eq!(
1718            matched.compatible_release_ids().collect::<Vec<_>>(),
1719            ["release-a", "release-b"]
1720        );
1721        assert_eq!(matched.informative_resource_count(), 1);
1722    }
1723
1724    #[test]
1725    fn missing_reference_is_unknown_and_cannot_become_a_mismatch() {
1726        let mut fixture = literal_catalogue();
1727        fixture["components"][0]["releases"][0]["files"]
1728            .as_array_mut()
1729            .unwrap()
1730            .remove(1);
1731        let encoded = serde_json::to_vec(&fixture).unwrap();
1732        let catalog = parse_wordpress_asset_fingerprint_catalog(&encoded).unwrap();
1733        let matched = match_wordpress_asset_fingerprint_component(
1734            &catalog,
1735            &identity(),
1736            &[
1737                observed("assets/app.js", 11, SHA_JS_AB),
1738                observed("assets/app.css", 22, SHA_CSS_BC),
1739            ],
1740        )
1741        .unwrap();
1742        assert_eq!(
1743            matched.state(),
1744            WordPressAssetFingerprintAggregateState::ProvisionalCandidates
1745        );
1746        assert_eq!(
1747            matched.undetermined_release_ids().collect::<Vec<_>>(),
1748            ["release-a"]
1749        );
1750        let css = matched
1751            .resources()
1752            .iter()
1753            .find(|resource| resource.relative_path() == "assets/app.css")
1754            .unwrap();
1755        assert_eq!(
1756            css.releases()[0].relation(),
1757            WordPressAssetFingerprintRelation::Unknown
1758        );
1759        assert_eq!(
1760            css.releases()[0].unknown_reason(),
1761            Some(WordPressAssetFingerprintUnknownReason::MissingReference)
1762        );
1763    }
1764
1765    #[test]
1766    fn contradictory_complete_observations_do_not_choose_a_favourite() {
1767        let catalog = parse_fixture();
1768        let matched = match_wordpress_asset_fingerprint_component(
1769            &catalog,
1770            &identity(),
1771            &[
1772                observed("assets/app.js", 11, SHA_JS_AB),
1773                observed("assets/app.js", 12, SHA_JS_C),
1774            ],
1775        )
1776        .unwrap();
1777        assert_eq!(
1778            matched.state(),
1779            WordPressAssetFingerprintAggregateState::Undetermined
1780        );
1781        assert_eq!(matched.resources()[0].distinct_observation_count(), 2);
1782        assert!(matched.releases().iter().all(|release| {
1783            release.state() == WordPressAssetFingerprintReleaseState::Undetermined
1784        }));
1785        assert!(matched.resources()[0].releases().iter().all(|release| {
1786            release.unknown_reason()
1787                == Some(WordPressAssetFingerprintUnknownReason::ConflictingObservations)
1788        }));
1789    }
1790
1791    #[test]
1792    fn no_consistent_release_and_no_byte_match_are_distinct() {
1793        let catalog = parse_fixture();
1794        let mixed = match_wordpress_asset_fingerprint_component(
1795            &catalog,
1796            &identity(),
1797            &[
1798                observed("assets/app.js", 11, SHA_JS_AB),
1799                observed("assets/app.css", 23, SHA_OTHER),
1800            ],
1801        )
1802        .unwrap();
1803        assert_eq!(
1804            mixed.state(),
1805            WordPressAssetFingerprintAggregateState::NoConsistentCatalogueRelease
1806        );
1807        let none = match_wordpress_asset_fingerprint_component(
1808            &catalog,
1809            &identity(),
1810            &[
1811                observed("assets/app.js", 13, SHA_OTHER),
1812                observed("assets/app.css", 23, SHA_OTHER),
1813            ],
1814        )
1815        .unwrap();
1816        assert_eq!(
1817            none.state(),
1818            WordPressAssetFingerprintAggregateState::NoCatalogueByteMatch
1819        );
1820    }
1821
1822    #[test]
1823    fn unlisted_component_and_empty_observations_remain_undetermined() {
1824        let catalog = parse_fixture();
1825        let unlisted =
1826            WordPressComponentIdentity::new(WordPressComponentKind::Theme, "unlisted-theme")
1827                .unwrap();
1828        let matched =
1829            match_wordpress_asset_fingerprint_component(&catalog, &unlisted, &[]).unwrap();
1830        assert!(!matched.catalogue_component_listed());
1831        assert_eq!(
1832            matched.state(),
1833            WordPressAssetFingerprintAggregateState::Undetermined
1834        );
1835        let empty =
1836            match_wordpress_asset_fingerprint_component(&catalog, &identity(), &[]).unwrap();
1837        assert!(empty.catalogue_component_listed());
1838        assert_eq!(
1839            empty.state(),
1840            WordPressAssetFingerprintAggregateState::Undetermined
1841        );
1842    }
1843
1844    #[test]
1845    fn strict_parser_rejects_unknown_duplicate_and_unsafe_values() {
1846        let duplicate = br#"{
1847            "schema":"security.wordpress-asset-fingerprint-catalog/v1",
1848            "schema":"security.wordpress-asset-fingerprint-catalog/v1"
1849        }"#;
1850        assert_eq!(
1851            parse_wordpress_asset_fingerprint_catalog(duplicate),
1852            Err(WordPressAssetFingerprintError::DuplicateKey)
1853        );
1854
1855        let mut unknown = literal_catalogue();
1856        unknown["execute"] = json!(true);
1857        assert_eq!(
1858            parse_wordpress_asset_fingerprint_catalog(&serde_json::to_vec(&unknown).unwrap()),
1859            Err(WordPressAssetFingerprintError::InvalidCatalog)
1860        );
1861
1862        for invalid in [
1863            "../app.js",
1864            "/assets/app.js",
1865            "assets\\app.js",
1866            "assets/%61pp.js",
1867            "assets/app.js?ver=1",
1868            "assets/app.js#fragment",
1869            "assets/app.min.js.map",
1870            "assets/app.php",
1871        ] {
1872            let mut value = literal_catalogue();
1873            value["components"][0]["releases"][0]["files"][0]["path"] = json!(invalid);
1874            assert_eq!(
1875                parse_wordpress_asset_fingerprint_catalog(&serde_json::to_vec(&value).unwrap()),
1876                Err(WordPressAssetFingerprintError::InvalidCatalog),
1877                "{invalid}"
1878            );
1879        }
1880    }
1881
1882    #[test]
1883    fn strict_parser_rejects_wrong_digest_length_bool_length_and_duplicate_identities() {
1884        let mut uppercase = literal_catalogue();
1885        uppercase["components"][0]["releases"][0]["files"][0]["sha256"] =
1886            json!(SHA_CSS_A.to_ascii_uppercase());
1887        assert_eq!(
1888            parse_wordpress_asset_fingerprint_catalog(&serde_json::to_vec(&uppercase).unwrap()),
1889            Err(WordPressAssetFingerprintError::InvalidCatalog)
1890        );
1891
1892        let mut boolean = literal_catalogue();
1893        boolean["components"][0]["releases"][0]["files"][0]["byte_length"] = json!(true);
1894        assert_eq!(
1895            parse_wordpress_asset_fingerprint_catalog(&serde_json::to_vec(&boolean).unwrap()),
1896            Err(WordPressAssetFingerprintError::InvalidCatalog)
1897        );
1898
1899        let mut duplicate_release = literal_catalogue();
1900        let duplicate = duplicate_release["components"][0]["releases"][0].clone();
1901        duplicate_release["components"][0]["releases"]
1902            .as_array_mut()
1903            .unwrap()
1904            .push(duplicate);
1905        assert_eq!(
1906            parse_wordpress_asset_fingerprint_catalog(
1907                &serde_json::to_vec(&duplicate_release).unwrap()
1908            ),
1909            Err(WordPressAssetFingerprintError::DuplicateIdentity)
1910        );
1911
1912        let mut duplicate_path = literal_catalogue();
1913        let duplicate = duplicate_path["components"][0]["releases"][0]["files"][0].clone();
1914        duplicate_path["components"][0]["releases"][0]["files"]
1915            .as_array_mut()
1916            .unwrap()
1917            .push(duplicate);
1918        assert_eq!(
1919            parse_wordpress_asset_fingerprint_catalog(
1920                &serde_json::to_vec(&duplicate_path).unwrap()
1921            ),
1922            Err(WordPressAssetFingerprintError::DuplicateIdentity)
1923        );
1924    }
1925
1926    #[test]
1927    fn parser_and_matcher_enforce_independent_bounds() {
1928        let mut too_many_components = literal_catalogue();
1929        let template = too_many_components["components"][0].clone();
1930        let components = too_many_components["components"].as_array_mut().unwrap();
1931        for index in 1..=MAX_WORDPRESS_ASSET_FINGERPRINT_CATALOG_COMPONENTS {
1932            let mut component = template.clone();
1933            component["slug"] = json!(format!("matrix-plugin-{index}"));
1934            components.push(component);
1935        }
1936        assert_eq!(
1937            parse_wordpress_asset_fingerprint_catalog(
1938                &serde_json::to_vec(&too_many_components).unwrap()
1939            ),
1940            Err(WordPressAssetFingerprintError::InvalidCatalog)
1941        );
1942
1943        let catalog = parse_fixture();
1944        let observations = [
1945            observed("assets/app.js", 11, SHA_JS_AB),
1946            observed("assets/app.css", 22, SHA_CSS_BC),
1947            observed("assets/third.js", 7, SHA_OTHER),
1948        ];
1949        assert_eq!(
1950            match_wordpress_asset_fingerprint_component(&catalog, &identity(), &observations),
1951            Err(WordPressAssetFingerprintError::MatchLimitExceeded)
1952        );
1953    }
1954}