1use 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
422pub enum WordPressAssetFingerprintRelation {
423 Match,
424 Mismatch,
425 Unknown,
426}
427
428#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
430pub enum WordPressAssetFingerprintUnknownReason {
431 MissingReference,
432 ConflictingObservations,
433}
434
435#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
437pub enum WordPressAssetFingerprintReleaseState {
438 Compatible,
439 Inconsistent,
440 Undetermined,
441}
442
443#[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#[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#[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#[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#[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 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
673pub 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
874pub 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, ¬ice_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(¬ice.id)
1050 || !valid_bounded_text(¬ice.party, MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICE_BYTES)
1051 || !valid_bounded_text(¬ice.notice, MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICE_BYTES)
1052 || !valid_bounded_text(
1053 ¬ice.license,
1054 MAX_WORDPRESS_ASSET_FINGERPRINT_NOTICE_BYTES,
1055 )
1056 {
1057 return Err(WordPressAssetFingerprintError::InvalidCatalog);
1058 }
1059 validate_reference(¬ice.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(¬ice_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}