1use std::{
9 collections::{BTreeMap, BTreeSet},
10 fmt,
11 io::{BufReader, ErrorKind, Read},
12 mem::size_of,
13};
14
15use serde::{
16 de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
17 Deserialize,
18};
19use serde_json::{Number, Value};
20use sha2::{Digest, Sha256};
21use thiserror::Error;
22use url::Url;
23
24use super::{WordPressComponentIdentity, WordPressComponentKind};
25
26pub const WORDFENCE_V3_SOURCE_NAMESPACE: &str = "wordfence-intelligence";
27pub const WORDFENCE_V3_PRODUCTION_FORMAT: &str = "wordfence-v3-production";
28pub const WORDFENCE_V3_MAPPING_REVISION: &str = "termivar-wordfence-v3-production/v1";
29pub const WORDFENCE_V3_MAPPING_REVISION_V2: &str = "termivar-wordfence-v3-production/v2";
30pub const WORDFENCE_V3_IDENTITY_MAPPING_POLICY: &str =
31 "termivar.wordfence-v3-exact-plus-ascii-lowercase-candidate/v1";
32pub const WORDFENCE_V3_RESOURCE_POLICY_V1: &str = "termivar.wordfence-v3-bounded-capacity/v1";
33pub const WORDFENCE_V3_RESOURCE_POLICY_V2: &str = "termivar.wordfence-v3-bounded-capacity/v2";
34pub const WORDFENCE_V3_RESOURCE_POLICY_V3: &str = "termivar.wordfence-v3-bounded-capacity/v3";
35pub const MAX_WORDFENCE_V3_PRODUCTION_BYTES: usize = 256 * 1024 * 1024;
36pub const MAX_WORDFENCE_V3_RECORDS: usize = 100_000;
37pub const MAX_WORDFENCE_V3_SOFTWARE_ASSOCIATIONS: usize = 200_000;
38pub const MAX_WORDFENCE_V3_RECORD_BYTES: usize = 768 * 1024;
39pub const MAX_WORDFENCE_V3_RETAINED_BYTES: usize = 160 * 1024 * 1024;
40pub const MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION: usize = 128;
41pub const MAX_WORDFENCE_V3_PATCHED_VERSIONS: usize = 128;
42pub const MAX_WORDFENCE_V3_REFERENCES: usize = 64;
43pub const MAX_WORDFENCE_V3_RESEARCHERS: usize = 64;
44pub const MAX_WORDFENCE_V3_NOTICE_PARTIES: usize = 15;
47pub(crate) const MAX_WORDFENCE_V3_SOFTWARE_PER_RECORD: usize = 2_048;
48const MAX_WORDFENCE_V3_JSON_NODES_PER_RECORD: usize = 32_768;
49const MAX_WORDFENCE_V3_ARRAY_ITEMS_PER_RECORD: usize = 4_096;
50const MAX_WORDFENCE_V3_OBJECT_MEMBERS_PER_RECORD: usize = 128;
54const MAX_WORDFENCE_V3_JSON_DEPTH: usize = 10;
55const MAX_WORDFENCE_V3_JSON_KEY_BYTES: usize = 256;
56const MAX_WORDFENCE_V3_JSON_STRING_BYTES: usize = 4_096;
57const WORDFENCE_DUPLICATE_KEY_MARKER: &str = "termivar_wordfence_duplicate_key";
58const WORDFENCE_JSON_LIMIT_MARKER: &str = "termivar_wordfence_json_limit";
59const CONSERVATIVE_BTREE_NODE_BYTES_PER_ENTRY: usize = 64;
63const MAX_WORDFENCE_V3_TITLE_BYTES: usize = 1_024;
64const MAX_WORDFENCE_V3_DESCRIPTION_BYTES: usize = 4_096;
65const MAX_WORDFENCE_V3_REMEDIATION_BYTES: usize = 2_048;
66const MAX_WORDFENCE_V3_NOTICE_TEXT_BYTES: usize = 2_048;
67const MAX_WORDFENCE_V3_NAME_BYTES: usize = 1_024;
68const MAX_WORDFENCE_V3_REFERENCE_BYTES: usize = 2_048;
69const MAX_WORDFENCE_V3_VERSION_BYTES: usize = 64;
70const MAX_WORDFENCE_V3_RANGE_LABEL_BYTES: usize = 256;
71pub const MAX_WORDFENCE_V3_RESEARCHER_BYTES: usize = 512;
72const MAX_WORDFENCE_V3_CVE_BYTES: usize = 32;
73const MAX_WORDFENCE_V3_CVSS_VECTOR_BYTES: usize = 256;
74const MAX_WORDFENCE_V3_SOURCE_SLUG_BYTES: usize = 128;
75const MAX_WORDFENCE_V3_RECORD_BYTES_V1: usize = 512 * 1024;
76const MAX_WORDFENCE_V3_RETAINED_BYTES_V1: usize = 64 * 1024 * 1024;
77const MAX_WORDFENCE_V3_RETAINED_BYTES_V2: usize = 128 * 1024 * 1024;
78const MAX_WORDFENCE_V3_DESCRIPTION_BYTES_V1: usize = 2_048;
79const MAX_WORDFENCE_V3_COLLECTION_ITEMS_V1: usize = 64;
80
81type AssociationCoordinate = (usize, usize);
82type ComponentAssociationIndex = BTreeMap<WordPressComponentIdentity, Vec<AssociationCoordinate>>;
83
84#[derive(Debug, Error, Clone, Copy, Eq, PartialEq)]
85pub enum WordfenceV3ProductionError {
86 #[error("Wordfence Production input could not be read")]
87 ReadFailed,
88 #[error("Wordfence Production input is empty")]
89 EmptyInput,
90 #[error("Wordfence Production input exceeds its byte limit")]
91 InputTooLarge,
92 #[error("a Wordfence Production record exceeds its byte limit")]
93 RecordTooLarge,
94 #[error("Wordfence Production input is malformed JSON")]
95 MalformedJson,
96 #[error("Wordfence Production input contains a duplicate object key")]
97 DuplicateKey,
98 #[error("Wordfence Production input exceeds a structural limit")]
99 StructuralLimitExceeded,
100 #[error("Wordfence Production input contains an unsupported field or value")]
101 UnsupportedValue,
102 #[error("Wordfence Production input contains a conflicting identity")]
103 ConflictingIdentity,
104 #[error("Wordfence Production retained data exceeds its memory limit")]
105 RetainedDataTooLarge,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct WordfenceV3ProductionImport {
110 byte_length: u64,
111 sha256: [u8; 32],
112 semantic_sha256: [u8; 32],
113 retained_bytes: usize,
114 software_association_count: usize,
115 affected_range_count: usize,
116 identity_counts: WordfenceV3IdentityCounts,
117 resource_policy: &'static str,
118 records: Vec<WordfenceV3Record>,
119 notices: Vec<WordfenceV3Notice>,
120 component_index: ComponentAssociationIndex,
121}
122
123pub type WordfenceV3Catalog = WordfenceV3ProductionImport;
124
125impl WordfenceV3ProductionImport {
126 #[must_use]
127 pub const fn byte_length(&self) -> u64 {
128 self.byte_length
129 }
130
131 #[must_use]
132 pub const fn sha256(&self) -> &[u8; 32] {
133 &self.sha256
134 }
135
136 #[must_use]
137 pub const fn semantic_sha256(&self) -> &[u8; 32] {
138 &self.semantic_sha256
139 }
140
141 #[must_use]
142 pub const fn retained_bytes(&self) -> usize {
143 self.retained_bytes
144 }
145
146 #[must_use]
147 pub fn record_count(&self) -> usize {
148 self.records.len()
149 }
150
151 #[must_use]
152 pub const fn software_association_count(&self) -> usize {
153 self.software_association_count
154 }
155
156 #[must_use]
157 pub const fn affected_range_count(&self) -> usize {
158 self.affected_range_count
159 }
160
161 #[must_use]
162 pub const fn identity_counts(&self) -> &WordfenceV3IdentityCounts {
163 &self.identity_counts
164 }
165
166 #[must_use]
167 pub const fn mapping_revision(&self) -> &'static str {
168 if self.identity_counts.requires_source_identity_mapping() {
169 WORDFENCE_V3_MAPPING_REVISION_V2
170 } else {
171 WORDFENCE_V3_MAPPING_REVISION
172 }
173 }
174
175 #[must_use]
176 pub const fn resource_policy(&self) -> &'static str {
177 self.resource_policy
178 }
179
180 #[must_use]
181 pub fn records(&self) -> &[WordfenceV3Record] {
182 &self.records
183 }
184
185 #[must_use]
186 pub fn notices(&self) -> &[WordfenceV3Notice] {
187 &self.notices
188 }
189
190 pub fn associations_for<'a>(
191 &'a self,
192 component: &'a WordPressComponentIdentity,
193 ) -> impl Iterator<Item = WordfenceV3AssociationView<'a>> + 'a {
194 self.component_index
195 .get(component)
196 .into_iter()
197 .flat_map(move |coordinates| {
198 coordinates
199 .iter()
200 .map(move |&(record_index, association_index)| {
201 let record = &self.records[record_index];
202 WordfenceV3AssociationView {
203 record,
204 association: &record.software[association_index],
205 }
206 })
207 })
208 }
209}
210
211#[derive(Clone, Debug, Eq, PartialEq)]
212pub struct WordfenceV3Record {
213 upstream_id: String,
214 title: String,
215 informational: bool,
216 description: String,
217 references: Vec<String>,
218 cwe: Option<WordfenceV3Cwe>,
219 cvss: Option<WordfenceV3Cvss>,
220 cve: Option<String>,
221 cve_link: Option<String>,
222 researchers: Vec<String>,
223 published: Option<String>,
224 updated: Option<String>,
225 notice_ids: Vec<String>,
226 software: Vec<WordfenceV3SoftwareAssociation>,
227 unresolved_software: Vec<WordfenceV3UnresolvedSoftwareAssociation>,
228}
229
230impl WordfenceV3Record {
231 #[must_use]
232 pub fn upstream_id(&self) -> &str {
233 &self.upstream_id
234 }
235 #[must_use]
236 pub fn title(&self) -> &str {
237 &self.title
238 }
239 #[must_use]
240 pub const fn informational(&self) -> bool {
241 self.informational
242 }
243 #[must_use]
244 pub fn description(&self) -> &str {
245 &self.description
246 }
247 #[must_use]
248 pub fn references(&self) -> &[String] {
249 &self.references
250 }
251 #[must_use]
252 pub const fn cwe(&self) -> Option<&WordfenceV3Cwe> {
253 self.cwe.as_ref()
254 }
255 #[must_use]
256 pub const fn cvss(&self) -> Option<&WordfenceV3Cvss> {
257 self.cvss.as_ref()
258 }
259 #[must_use]
260 pub fn cve(&self) -> Option<&str> {
261 self.cve.as_deref()
262 }
263 #[must_use]
264 pub fn cve_link(&self) -> Option<&str> {
265 self.cve_link.as_deref()
266 }
267 #[must_use]
268 pub fn researchers(&self) -> &[String] {
269 &self.researchers
270 }
271 #[must_use]
272 pub fn published(&self) -> Option<&str> {
273 self.published.as_deref()
274 }
275 #[must_use]
276 pub fn updated(&self) -> Option<&str> {
277 self.updated.as_deref()
278 }
279 #[must_use]
280 pub fn notice_ids(&self) -> &[String] {
281 &self.notice_ids
282 }
283 #[must_use]
284 pub fn software(&self) -> &[WordfenceV3SoftwareAssociation] {
285 &self.software
286 }
287
288 #[must_use]
289 pub fn unresolved_software(&self) -> &[WordfenceV3UnresolvedSoftwareAssociation] {
290 &self.unresolved_software
291 }
292}
293
294#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
295pub enum WordfenceV3IdentityMapping {
296 Exact,
297 AsciiCaseFoldCandidate,
298 AsciiCaseFoldAmbiguous,
299}
300
301impl WordfenceV3IdentityMapping {
302 #[must_use]
303 pub const fn as_str(self) -> &'static str {
304 match self {
305 Self::Exact => "exact",
306 Self::AsciiCaseFoldCandidate => "ascii_case_fold_candidate",
307 Self::AsciiCaseFoldAmbiguous => "ascii_case_fold_ambiguous",
308 }
309 }
310}
311
312#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
313pub struct WordfenceV3SourceComponentIdentity {
314 kind: WordPressComponentKind,
315 slug: String,
316}
317
318impl WordfenceV3SourceComponentIdentity {
319 #[must_use]
320 pub const fn kind(&self) -> WordPressComponentKind {
321 self.kind
322 }
323
324 #[must_use]
325 pub fn slug(&self) -> &str {
326 &self.slug
327 }
328}
329
330#[derive(Clone, Debug, Default, Eq, PartialEq)]
331pub struct WordfenceV3IdentityCounts {
332 exact: usize,
333 candidate: usize,
334 ambiguous: usize,
335 unresolved: usize,
336}
337
338impl WordfenceV3IdentityCounts {
339 #[must_use]
340 pub const fn exact(&self) -> usize {
341 self.exact
342 }
343
344 #[must_use]
345 pub const fn candidate(&self) -> usize {
346 self.candidate
347 }
348
349 #[must_use]
350 pub const fn ambiguous(&self) -> usize {
351 self.ambiguous
352 }
353
354 #[must_use]
355 pub const fn unresolved(&self) -> usize {
356 self.unresolved
357 }
358
359 const fn requires_source_identity_mapping(&self) -> bool {
360 self.candidate != 0 || self.ambiguous != 0 || self.unresolved != 0
361 }
362}
363
364#[derive(Clone, Debug, Eq, PartialEq)]
365pub struct WordfenceV3SoftwareAssociation {
366 key: WordfenceV3AssociationKey,
367 identity_mapping: WordfenceV3IdentityMapping,
368 identity_collision_raw_count: Option<usize>,
369 display_name: String,
370 affected_ranges: Vec<WordfenceV3AffectedRange>,
371 patched: bool,
372 patched_versions: Vec<String>,
373 remediation: String,
374}
375
376impl WordfenceV3SoftwareAssociation {
377 #[must_use]
378 pub const fn key(&self) -> &WordfenceV3AssociationKey {
379 &self.key
380 }
381 #[must_use]
382 pub const fn component(&self) -> &WordPressComponentIdentity {
383 &self.key.component
384 }
385 #[must_use]
386 pub const fn source_component(&self) -> &WordfenceV3SourceComponentIdentity {
387 &self.key.source_component
388 }
389 #[must_use]
390 pub const fn identity_mapping(&self) -> WordfenceV3IdentityMapping {
391 self.identity_mapping
392 }
393 #[must_use]
394 pub const fn identity_collision_raw_count(&self) -> Option<usize> {
395 self.identity_collision_raw_count
396 }
397 #[must_use]
398 pub fn display_name(&self) -> &str {
399 &self.display_name
400 }
401 #[must_use]
402 pub fn affected_ranges(&self) -> &[WordfenceV3AffectedRange] {
403 &self.affected_ranges
404 }
405 #[must_use]
406 pub const fn patched(&self) -> bool {
407 self.patched
408 }
409 #[must_use]
410 pub fn patched_versions(&self) -> &[String] {
411 &self.patched_versions
412 }
413 #[must_use]
414 pub fn remediation(&self) -> &str {
415 &self.remediation
416 }
417}
418
419#[derive(Clone, Debug, Eq, PartialEq)]
420pub struct WordfenceV3UnresolvedSoftwareAssociation {
421 upstream_id: String,
422 source_component: WordfenceV3SourceComponentIdentity,
423 source_association_sha256: [u8; 32],
424 display_name: String,
425 affected_ranges: Vec<WordfenceV3AffectedRange>,
426 patched: bool,
427 patched_versions: Vec<String>,
428 remediation: String,
429}
430
431impl WordfenceV3UnresolvedSoftwareAssociation {
432 #[must_use]
433 pub fn upstream_id(&self) -> &str {
434 &self.upstream_id
435 }
436
437 #[must_use]
438 pub const fn source_component(&self) -> &WordfenceV3SourceComponentIdentity {
439 &self.source_component
440 }
441
442 #[must_use]
443 pub const fn source_association_sha256(&self) -> &[u8; 32] {
444 &self.source_association_sha256
445 }
446
447 #[must_use]
448 pub fn display_name(&self) -> &str {
449 &self.display_name
450 }
451
452 #[must_use]
453 pub fn affected_ranges(&self) -> &[WordfenceV3AffectedRange] {
454 &self.affected_ranges
455 }
456
457 #[must_use]
458 pub const fn patched(&self) -> bool {
459 self.patched
460 }
461
462 #[must_use]
463 pub fn patched_versions(&self) -> &[String] {
464 &self.patched_versions
465 }
466
467 #[must_use]
468 pub fn remediation(&self) -> &str {
469 &self.remediation
470 }
471}
472
473#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
474pub struct WordfenceV3AssociationKey {
475 upstream_id: String,
476 component: WordPressComponentIdentity,
477 source_component: WordfenceV3SourceComponentIdentity,
478 source_association_sha256: [u8; 32],
479}
480
481impl WordfenceV3AssociationKey {
482 #[must_use]
483 pub const fn source_namespace(&self) -> &'static str {
484 WORDFENCE_V3_SOURCE_NAMESPACE
485 }
486 #[must_use]
487 pub fn upstream_id(&self) -> &str {
488 &self.upstream_id
489 }
490 #[must_use]
491 pub const fn component(&self) -> &WordPressComponentIdentity {
492 &self.component
493 }
494
495 #[must_use]
496 pub const fn source_component(&self) -> &WordfenceV3SourceComponentIdentity {
497 &self.source_component
498 }
499
500 #[must_use]
501 pub const fn source_association_sha256(&self) -> &[u8; 32] {
502 &self.source_association_sha256
503 }
504}
505
506#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
507pub struct WordfenceV3AffectedRange {
508 label: String,
509 from: WordfenceV3RangeEndpoint,
510 to: WordfenceV3RangeEndpoint,
511}
512
513impl WordfenceV3AffectedRange {
514 #[must_use]
515 pub fn label(&self) -> &str {
516 &self.label
517 }
518 #[must_use]
519 pub const fn from(&self) -> &WordfenceV3RangeEndpoint {
520 &self.from
521 }
522 #[must_use]
523 pub const fn to(&self) -> &WordfenceV3RangeEndpoint {
524 &self.to
525 }
526}
527
528#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
529pub struct WordfenceV3RangeEndpoint {
530 value: WordfenceV3RangeValue,
531 inclusive: bool,
532}
533
534impl WordfenceV3RangeEndpoint {
535 #[must_use]
536 pub const fn value(&self) -> &WordfenceV3RangeValue {
537 &self.value
538 }
539 #[must_use]
540 pub const fn inclusive(&self) -> bool {
541 self.inclusive
542 }
543}
544
545#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
546pub enum WordfenceV3RangeValue {
547 Any,
548 Declared(String),
549}
550
551impl WordfenceV3RangeValue {
552 #[must_use]
553 pub fn declared(&self) -> &str {
554 match self {
555 Self::Any => "*",
556 Self::Declared(value) => value,
557 }
558 }
559}
560
561#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
562pub struct WordfenceV3Cwe {
563 id: u32,
564 name: String,
565 description: String,
566}
567
568impl WordfenceV3Cwe {
569 #[must_use]
570 pub const fn id(&self) -> u32 {
571 self.id
572 }
573 #[must_use]
574 pub fn name(&self) -> &str {
575 &self.name
576 }
577 #[must_use]
578 pub fn description(&self) -> &str {
579 &self.description
580 }
581}
582
583#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
584pub struct WordfenceV3Cvss {
585 vector: String,
586 score: String,
587 rating: WordfenceV3CvssRating,
588}
589
590impl WordfenceV3Cvss {
591 #[must_use]
592 pub fn vector(&self) -> &str {
593 &self.vector
594 }
595 #[must_use]
596 pub fn score(&self) -> &str {
597 &self.score
598 }
599 #[must_use]
600 pub const fn rating(&self) -> WordfenceV3CvssRating {
601 self.rating
602 }
603}
604
605#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
606pub enum WordfenceV3CvssRating {
607 None,
608 Low,
609 Medium,
610 High,
611 Critical,
612}
613
614#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
615pub struct WordfenceV3Notice {
616 id: String,
617 message: String,
618 party: String,
619 notice: String,
620 license: String,
621 license_url: String,
622}
623
624impl WordfenceV3Notice {
625 #[must_use]
626 pub fn id(&self) -> &str {
627 &self.id
628 }
629 #[must_use]
630 pub fn message(&self) -> &str {
631 &self.message
632 }
633 #[must_use]
634 pub fn party(&self) -> &str {
635 &self.party
636 }
637 #[must_use]
638 pub fn notice(&self) -> &str {
639 &self.notice
640 }
641 #[must_use]
642 pub fn license(&self) -> &str {
643 &self.license
644 }
645 #[must_use]
646 pub fn license_url(&self) -> &str {
647 &self.license_url
648 }
649}
650
651#[derive(Clone, Copy)]
652pub struct WordfenceV3AssociationView<'a> {
653 record: &'a WordfenceV3Record,
654 association: &'a WordfenceV3SoftwareAssociation,
655}
656
657impl<'a> WordfenceV3AssociationView<'a> {
658 #[must_use]
659 pub const fn record(self) -> &'a WordfenceV3Record {
660 self.record
661 }
662 #[must_use]
663 pub const fn association(self) -> &'a WordfenceV3SoftwareAssociation {
664 self.association
665 }
666 #[must_use]
667 pub fn upstream_id(self) -> &'a str {
668 self.record.upstream_id()
669 }
670 #[must_use]
671 pub const fn component(self) -> &'a WordPressComponentIdentity {
672 self.association.component()
673 }
674}
675
676pub fn parse_wordfence_v3_production<R: Read>(
680 reader: R,
681) -> Result<WordfenceV3ProductionImport, WordfenceV3ProductionError> {
682 parse_wordfence_v3_production_with_limits(
683 reader,
684 MAX_WORDFENCE_V3_PRODUCTION_BYTES,
685 MAX_WORDFENCE_V3_RETAINED_BYTES,
686 )
687}
688
689fn parse_wordfence_v3_production_with_limits<R: Read>(
690 reader: R,
691 maximum_input_bytes: usize,
692 maximum_retained_bytes: usize,
693) -> Result<WordfenceV3ProductionImport, WordfenceV3ProductionError> {
694 let mut cursor = MeasuredCursor::with_limit(reader, maximum_input_bytes);
695 let mut retained_during_parse = RetainedBudget::with_base(
696 maximum_retained_bytes,
697 size_of::<WordfenceV3ProductionImport>(),
698 )?;
699 let first = cursor.next_non_whitespace()?;
700 if first.is_none() && cursor.byte_length == 0 {
701 return Err(WordfenceV3ProductionError::EmptyInput);
702 }
703 if first != Some(b'{') {
704 return Err(WordfenceV3ProductionError::MalformedJson);
705 }
706
707 let mut upstream_ids = BTreeSet::new();
708 let mut records = Vec::new();
709 let mut notices = BTreeMap::<String, WordfenceV3Notice>::new();
710 let mut software_association_count = 0_usize;
711 let mut affected_range_count = 0_usize;
712 let mut requires_resource_policy_v2 = false;
713 let mut requires_semantic_digest_v2 = false;
714 let mut delimiter = cursor.next_non_whitespace()?;
715 if delimiter != Some(b'}') {
716 loop {
717 if delimiter != Some(b'"') {
718 return Err(WordfenceV3ProductionError::MalformedJson);
719 }
720 if records.len() == MAX_WORDFENCE_V3_RECORDS {
721 return Err(WordfenceV3ProductionError::StructuralLimitExceeded);
722 }
723 let upstream_id = cursor.capture_string()?;
724 validate_uuid(&upstream_id)?;
725 if upstream_ids.contains(&upstream_id) {
726 return Err(WordfenceV3ProductionError::DuplicateKey);
727 }
728 retained_during_parse.add_btree_entry(
729 size_of::<String>()
730 .checked_add(upstream_id.capacity())
731 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
732 )?;
733 upstream_ids.insert(upstream_id.clone());
734 if cursor.next_non_whitespace()? != Some(b':') {
735 return Err(WordfenceV3ProductionError::MalformedJson);
736 }
737 if cursor.next_non_whitespace()? != Some(b'{') {
738 return Err(WordfenceV3ProductionError::UnsupportedValue);
739 }
740 let record_bytes = cursor.capture_record_object()?;
741 let value = decode_bounded_record(&record_bytes)?;
742 let (record, record_notices) = decode_record(upstream_id, value)?;
743 requires_resource_policy_v2 |=
744 record_requires_resource_policy_v2(record_bytes.len(), &record);
745 requires_semantic_digest_v2 |= record_requires_semantic_digest_v2(&record);
746 for notice in record_notices {
747 let id = notice.id.clone();
748 if let Some(existing) = notices.get(&id) {
749 if existing != ¬ice {
750 return Err(WordfenceV3ProductionError::ConflictingIdentity);
751 }
752 } else {
753 retained_during_parse.add_notice_map_entry(&id, ¬ice)?;
754 notices.insert(id, notice);
755 }
756 }
757 let record_associations = record
758 .software
759 .len()
760 .checked_add(record.unresolved_software.len())
761 .ok_or(WordfenceV3ProductionError::StructuralLimitExceeded)?;
762 software_association_count = software_association_count
763 .checked_add(record_associations)
764 .ok_or(WordfenceV3ProductionError::StructuralLimitExceeded)?;
765 if software_association_count > MAX_WORDFENCE_V3_SOFTWARE_ASSOCIATIONS {
766 return Err(WordfenceV3ProductionError::StructuralLimitExceeded);
767 }
768 let ranges = record
769 .software
770 .iter()
771 .map(WordfenceV3SoftwareAssociation::affected_ranges)
772 .chain(
773 record
774 .unresolved_software
775 .iter()
776 .map(WordfenceV3UnresolvedSoftwareAssociation::affected_ranges),
777 )
778 .try_fold(0_usize, |count, ranges| count.checked_add(ranges.len()))
779 .ok_or(WordfenceV3ProductionError::StructuralLimitExceeded)?;
780 affected_range_count = affected_range_count
781 .checked_add(ranges)
782 .ok_or(WordfenceV3ProductionError::StructuralLimitExceeded)?;
783 retained_during_parse.add(record_dynamic_bytes(&record)?)?;
784 reserve_one_with_retained_accounting(&mut records, &mut retained_during_parse)?;
785 records.push(record);
786
787 delimiter = cursor.next_non_whitespace()?;
788 match delimiter {
789 Some(b',') => {
790 delimiter = cursor.next_non_whitespace()?;
791 if delimiter == Some(b'}') {
792 return Err(WordfenceV3ProductionError::MalformedJson);
793 }
794 },
795 Some(b'}') => break,
796 _ => return Err(WordfenceV3ProductionError::MalformedJson),
797 }
798 }
799 }
800 if cursor.next_non_whitespace()?.is_some() {
801 return Err(WordfenceV3ProductionError::MalformedJson);
802 }
803 let (byte_length, sha256) = cursor.finish();
804 drop(upstream_ids);
808
809 let (identity_counts, identity_resolution_peak) = resolve_identity_mappings(
810 &mut records,
811 retained_during_parse.total(),
812 maximum_retained_bytes,
813 )?;
814 records.sort_by(|left, right| left.upstream_id.cmp(&right.upstream_id));
815 let mut notice_values = Vec::new();
816 reserve_exact_capacity_with_retained_accounting(
817 &mut notice_values,
818 notices.len(),
819 &mut retained_during_parse,
820 )?;
821 notice_values.extend(notices.into_values());
822 let parse_peak_bytes = retained_during_parse.total().max(identity_resolution_peak);
823 let base_retained = retained_records_and_notices_bytes(
827 &records,
828 records.capacity(),
829 ¬ice_values,
830 notice_values.capacity(),
831 maximum_retained_bytes,
832 )?;
833 let (component_index, retained_bytes) =
834 build_component_index(&records, base_retained, maximum_retained_bytes)?;
835 let independently_measured = retained_import_bytes(
836 &records,
837 records.capacity(),
838 ¬ice_values,
839 notice_values.capacity(),
840 &component_index,
841 maximum_retained_bytes,
842 )?;
843 if retained_bytes != independently_measured {
844 return Err(WordfenceV3ProductionError::RetainedDataTooLarge);
845 }
846 let resource_policy = resource_policy_for_import(
851 requires_resource_policy_v2,
852 parse_peak_bytes,
853 retained_bytes,
854 );
855 let semantic_sha256 = semantic_digest(
856 &records,
857 ¬ice_values,
858 &identity_counts,
859 requires_semantic_digest_v2,
860 );
861 Ok(WordfenceV3ProductionImport {
862 byte_length,
863 sha256,
864 semantic_sha256,
865 retained_bytes,
866 software_association_count,
867 affected_range_count,
868 identity_counts,
869 resource_policy,
870 records,
871 notices: notice_values,
872 component_index,
873 })
874}
875
876fn resource_policy_for_import(
877 requires_expanded_record_semantics: bool,
878 parse_peak_bytes: usize,
879 retained_bytes: usize,
880) -> &'static str {
881 let import_envelope_bytes = retained_bytes.max(parse_peak_bytes);
882 if import_envelope_bytes > MAX_WORDFENCE_V3_RETAINED_BYTES_V2 {
883 WORDFENCE_V3_RESOURCE_POLICY_V3
884 } else if requires_expanded_record_semantics
885 || import_envelope_bytes > MAX_WORDFENCE_V3_RETAINED_BYTES_V1
886 {
887 WORDFENCE_V3_RESOURCE_POLICY_V2
888 } else {
889 WORDFENCE_V3_RESOURCE_POLICY_V1
890 }
891}
892
893fn resolve_identity_mappings(
894 records: &mut [WordfenceV3Record],
895 retained_base: usize,
896 maximum_retained: usize,
897) -> Result<(WordfenceV3IdentityCounts, usize), WordfenceV3ProductionError> {
898 let mut collision_budget = RetainedBudget::with_base(maximum_retained, retained_base)?;
903 let mut spellings = Vec::<(
904 WordPressComponentIdentity,
905 WordfenceV3SourceComponentIdentity,
906 )>::new();
907 for association in records
911 .iter()
912 .flat_map(|record| &record.software)
913 .filter(|association| {
914 association.identity_mapping == WordfenceV3IdentityMapping::AsciiCaseFoldCandidate
915 })
916 {
917 push_identity_collision_pair(&mut spellings, association, &mut collision_budget)?;
918 }
919 spellings.sort();
920 spellings.dedup();
921 let candidate_spelling_count = spellings.len();
922 for association in records
923 .iter()
924 .flat_map(|record| &record.software)
925 .filter(|association| association.identity_mapping == WordfenceV3IdentityMapping::Exact)
926 {
927 if spellings[..candidate_spelling_count]
928 .binary_search_by(|(component, _)| component.cmp(association.component()))
929 .is_err()
930 {
931 continue;
932 }
933 push_identity_collision_pair(&mut spellings, association, &mut collision_budget)?;
934 }
935 spellings.sort();
936 spellings.dedup();
937
938 let mut counts = WordfenceV3IdentityCounts::default();
939 for record in records.iter_mut() {
940 counts.unresolved = counts
941 .unresolved
942 .checked_add(record.unresolved_software.len())
943 .ok_or(WordfenceV3ProductionError::StructuralLimitExceeded)?;
944 for association in &mut record.software {
945 if association.identity_mapping == WordfenceV3IdentityMapping::AsciiCaseFoldCandidate {
946 let distinct_source_identities =
947 identity_collision_count(&spellings, association.component());
948 if distinct_source_identities > 1 {
949 association.identity_mapping =
950 WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous;
951 association.identity_collision_raw_count = Some(distinct_source_identities);
952 }
953 }
954 let count = match association.identity_mapping {
955 WordfenceV3IdentityMapping::Exact => &mut counts.exact,
956 WordfenceV3IdentityMapping::AsciiCaseFoldCandidate => &mut counts.candidate,
957 WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous => &mut counts.ambiguous,
958 };
959 *count = count
960 .checked_add(1)
961 .ok_or(WordfenceV3ProductionError::StructuralLimitExceeded)?;
962 }
963 }
964 Ok((counts, collision_budget.total()))
965}
966
967fn push_identity_collision_pair(
968 spellings: &mut Vec<(
969 WordPressComponentIdentity,
970 WordfenceV3SourceComponentIdentity,
971 )>,
972 association: &WordfenceV3SoftwareAssociation,
973 retained: &mut RetainedBudget,
974) -> Result<(), WordfenceV3ProductionError> {
975 retained.add(
976 association
977 .component()
978 .slug
979 .capacity()
980 .checked_add(association.source_component().slug.capacity())
981 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
982 )?;
983 reserve_one_with_retained_accounting(spellings, retained)?;
984 spellings.push((
985 association.component().clone(),
986 association.source_component().clone(),
987 ));
988 Ok(())
989}
990
991fn identity_collision_count(
992 spellings: &[(
993 WordPressComponentIdentity,
994 WordfenceV3SourceComponentIdentity,
995 )],
996 component: &WordPressComponentIdentity,
997) -> usize {
998 let start = spellings.partition_point(|(candidate, _)| candidate < component);
999 let end = spellings.partition_point(|(candidate, _)| candidate <= component);
1000 end.saturating_sub(start)
1001}
1002
1003fn record_requires_resource_policy_v2(record_bytes: usize, record: &WordfenceV3Record) -> bool {
1004 record_bytes > MAX_WORDFENCE_V3_RECORD_BYTES_V1 || record_requires_semantic_digest_v2(record)
1005}
1006
1007fn record_requires_semantic_digest_v2(record: &WordfenceV3Record) -> bool {
1008 record.description.len() > MAX_WORDFENCE_V3_DESCRIPTION_BYTES_V1
1009 || record_has_source_association_variants(record)
1010 || record.researchers.windows(2).any(|pair| pair[0] == pair[1])
1011 || record
1012 .cwe
1013 .as_ref()
1014 .is_some_and(|cwe| cwe.description.len() > MAX_WORDFENCE_V3_DESCRIPTION_BYTES_V1)
1015 || record
1016 .software
1017 .iter()
1018 .any(mapped_association_requires_resource_policy_v2)
1019 || record
1020 .unresolved_software
1021 .iter()
1022 .any(unresolved_association_requires_resource_policy_v2)
1023}
1024
1025fn record_has_source_association_variants(record: &WordfenceV3Record) -> bool {
1026 record.software.windows(2).any(|pair| {
1027 pair[0].key.upstream_id == pair[1].key.upstream_id
1028 && pair[0].source_component() == pair[1].source_component()
1029 }) || record.unresolved_software.windows(2).any(|pair| {
1030 pair[0].upstream_id == pair[1].upstream_id
1031 && pair[0].source_component == pair[1].source_component
1032 })
1033}
1034
1035fn mapped_association_requires_resource_policy_v2(
1036 association: &WordfenceV3SoftwareAssociation,
1037) -> bool {
1038 association.affected_ranges.len() > MAX_WORDFENCE_V3_COLLECTION_ITEMS_V1
1039 || association.patched_versions.len() > MAX_WORDFENCE_V3_COLLECTION_ITEMS_V1
1040 || source_versions_require_expanded_semantics(
1041 &association.affected_ranges,
1042 &association.patched_versions,
1043 )
1044}
1045
1046fn unresolved_association_requires_resource_policy_v2(
1047 association: &WordfenceV3UnresolvedSoftwareAssociation,
1048) -> bool {
1049 association.affected_ranges.len() > MAX_WORDFENCE_V3_COLLECTION_ITEMS_V1
1050 || association.patched_versions.len() > MAX_WORDFENCE_V3_COLLECTION_ITEMS_V1
1051 || source_versions_require_expanded_semantics(
1052 &association.affected_ranges,
1053 &association.patched_versions,
1054 )
1055}
1056
1057fn source_versions_require_expanded_semantics(
1058 ranges: &[WordfenceV3AffectedRange],
1059 patched_versions: &[String],
1060) -> bool {
1061 ranges.iter().any(|range| {
1062 [&range.from.value, &range.to.value]
1063 .into_iter()
1064 .any(|endpoint| match endpoint {
1065 WordfenceV3RangeValue::Any => false,
1066 WordfenceV3RangeValue::Declared(value) => source_version_requires_v2(value),
1067 })
1068 }) || patched_versions
1069 .iter()
1070 .any(|version| source_version_requires_v2(version))
1071}
1072
1073fn source_version_requires_v2(value: &str) -> bool {
1074 !value.is_ascii() || value.chars().any(char::is_whitespace)
1075}
1076
1077fn build_component_index(
1078 records: &[WordfenceV3Record],
1079 base_retained: usize,
1080 maximum_retained: usize,
1081) -> Result<(ComponentAssociationIndex, usize), WordfenceV3ProductionError> {
1082 use std::collections::btree_map::Entry;
1083
1084 let mut retained = RetainedBudget::with_base(maximum_retained, base_retained)?;
1085 let mut index = ComponentAssociationIndex::new();
1086 for (record_index, record) in records.iter().enumerate() {
1087 for (association_index, association) in record.software.iter().enumerate() {
1088 let coordinates = match index.entry(association.component().clone()) {
1089 Entry::Vacant(entry) => {
1090 retained.add_btree_entry(
1091 size_of::<WordPressComponentIdentity>()
1092 .checked_add(size_of::<Vec<AssociationCoordinate>>())
1093 .and_then(|bytes| bytes.checked_add(entry.key().slug.capacity()))
1094 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
1095 )?;
1096 entry.insert(Vec::new())
1097 },
1098 Entry::Occupied(entry) => entry.into_mut(),
1099 };
1100 reserve_one_with_retained_accounting(coordinates, &mut retained)?;
1101 coordinates.push((record_index, association_index));
1102 }
1103 }
1104 Ok((index, retained.total()))
1105}
1106
1107struct MeasuredCursor<R: Read> {
1108 reader: BufReader<std::io::Take<R>>,
1109 hasher: Sha256,
1110 byte_length: u64,
1111 maximum_bytes: u64,
1112}
1113
1114impl<R: Read> MeasuredCursor<R> {
1115 fn with_limit(reader: R, maximum_bytes: usize) -> Self {
1116 let maximum_bytes = u64::try_from(maximum_bytes).unwrap_or(u64::MAX);
1117 Self {
1118 reader: BufReader::with_capacity(
1121 64 * 1024,
1122 reader.take(maximum_bytes.saturating_add(1)),
1123 ),
1124 hasher: Sha256::new(),
1125 byte_length: 0,
1126 maximum_bytes,
1127 }
1128 }
1129
1130 fn next_byte(&mut self) -> Result<Option<u8>, WordfenceV3ProductionError> {
1131 let mut byte = [0_u8; 1];
1132 loop {
1133 match self.reader.read(&mut byte) {
1134 Ok(0) => return Ok(None),
1135 Ok(1) => {
1136 self.byte_length = self
1137 .byte_length
1138 .checked_add(1)
1139 .ok_or(WordfenceV3ProductionError::InputTooLarge)?;
1140 if self.byte_length > self.maximum_bytes {
1141 return Err(WordfenceV3ProductionError::InputTooLarge);
1142 }
1143 self.hasher.update(byte);
1144 return Ok(Some(byte[0]));
1145 },
1146 Ok(_) => unreachable!("one-byte read buffer cannot return more than one byte"),
1147 Err(error) if error.kind() == ErrorKind::Interrupted => continue,
1148 Err(_) => return Err(WordfenceV3ProductionError::ReadFailed),
1149 }
1150 }
1151 }
1152
1153 fn next_non_whitespace(&mut self) -> Result<Option<u8>, WordfenceV3ProductionError> {
1154 loop {
1155 let Some(byte) = self.next_byte()? else {
1156 return Ok(None);
1157 };
1158 if !matches!(byte, b' ' | b'\t' | b'\r' | b'\n') {
1159 return Ok(Some(byte));
1160 }
1161 }
1162 }
1163
1164 fn capture_string(&mut self) -> Result<String, WordfenceV3ProductionError> {
1165 let mut raw = vec![b'"'];
1166 let mut escaped = false;
1167 loop {
1168 let byte = self
1169 .next_byte()?
1170 .ok_or(WordfenceV3ProductionError::MalformedJson)?;
1171 raw.push(byte);
1172 if raw.len() > 256 {
1173 return Err(WordfenceV3ProductionError::StructuralLimitExceeded);
1174 }
1175 if escaped {
1176 escaped = false;
1177 } else if byte == b'\\' {
1178 escaped = true;
1179 } else if byte == b'"' {
1180 break;
1181 }
1182 }
1183 serde_json::from_slice(&raw).map_err(|_| WordfenceV3ProductionError::MalformedJson)
1184 }
1185
1186 fn capture_record_object(&mut self) -> Result<Vec<u8>, WordfenceV3ProductionError> {
1187 let mut raw = Vec::with_capacity(4096);
1188 raw.push(b'{');
1189 let mut stack = vec![b'{'];
1190 let mut in_string = false;
1191 let mut escaped = false;
1192 while !stack.is_empty() {
1193 let byte = self
1194 .next_byte()?
1195 .ok_or(WordfenceV3ProductionError::MalformedJson)?;
1196 raw.push(byte);
1197 if raw.len() > MAX_WORDFENCE_V3_RECORD_BYTES {
1198 return Err(WordfenceV3ProductionError::RecordTooLarge);
1199 }
1200 if in_string {
1201 if escaped {
1202 escaped = false;
1203 } else if byte == b'\\' {
1204 escaped = true;
1205 } else if byte == b'"' {
1206 in_string = false;
1207 }
1208 continue;
1209 }
1210 match byte {
1211 b'"' => in_string = true,
1212 b'{' | b'[' => {
1213 stack.push(byte);
1214 if stack.len() > 10 {
1215 return Err(WordfenceV3ProductionError::StructuralLimitExceeded);
1216 }
1217 },
1218 b'}' | b']' => {
1219 let expected = if byte == b'}' { b'{' } else { b'[' };
1220 if stack.pop() != Some(expected) {
1221 return Err(WordfenceV3ProductionError::MalformedJson);
1222 }
1223 },
1224 _ => {},
1225 }
1226 }
1227 Ok(raw)
1228 }
1229
1230 fn finish(self) -> (u64, [u8; 32]) {
1231 (self.byte_length, self.hasher.finalize().into())
1232 }
1233}
1234
1235fn decode_bounded_record(bytes: &[u8]) -> Result<Value, WordfenceV3ProductionError> {
1236 let limits = WordfenceJsonLimits {
1237 maximum_nodes: MAX_WORDFENCE_V3_JSON_NODES_PER_RECORD,
1238 maximum_array_length: MAX_WORDFENCE_V3_ARRAY_ITEMS_PER_RECORD,
1239 maximum_object_members: MAX_WORDFENCE_V3_OBJECT_MEMBERS_PER_RECORD,
1240 maximum_key_bytes: MAX_WORDFENCE_V3_JSON_KEY_BYTES,
1241 maximum_string_bytes: MAX_WORDFENCE_V3_JSON_STRING_BYTES,
1242 maximum_depth: MAX_WORDFENCE_V3_JSON_DEPTH,
1243 };
1244 let mut budget = WordfenceJsonBudget::default();
1245 let mut decoder = serde_json::Deserializer::from_slice(bytes);
1246 let value = WordfenceBoundedValueSeed {
1247 depth: 0,
1248 limits,
1249 budget: &mut budget,
1250 }
1251 .deserialize(&mut decoder)
1252 .map_err(classify_record_json_error)?;
1253 decoder.end().map_err(classify_record_json_error)?;
1254 Ok(value)
1255}
1256
1257fn classify_record_json_error(error: serde_json::Error) -> WordfenceV3ProductionError {
1258 let message = error.to_string();
1259 if message.contains(WORDFENCE_DUPLICATE_KEY_MARKER) {
1260 WordfenceV3ProductionError::DuplicateKey
1261 } else if message.contains(WORDFENCE_JSON_LIMIT_MARKER) {
1262 WordfenceV3ProductionError::StructuralLimitExceeded
1263 } else {
1264 WordfenceV3ProductionError::MalformedJson
1265 }
1266}
1267
1268#[derive(Clone, Copy)]
1269struct WordfenceJsonLimits {
1270 maximum_nodes: usize,
1271 maximum_array_length: usize,
1272 maximum_object_members: usize,
1273 maximum_key_bytes: usize,
1274 maximum_string_bytes: usize,
1275 maximum_depth: usize,
1276}
1277
1278#[derive(Default)]
1279struct WordfenceJsonBudget {
1280 nodes: usize,
1281}
1282
1283struct WordfenceBoundedValueSeed<'a> {
1284 depth: usize,
1285 limits: WordfenceJsonLimits,
1286 budget: &'a mut WordfenceJsonBudget,
1287}
1288
1289impl<'de> DeserializeSeed<'de> for WordfenceBoundedValueSeed<'_> {
1290 type Value = Value;
1291
1292 fn deserialize<D: de::Deserializer<'de>>(self, decoder: D) -> Result<Value, D::Error> {
1293 if self.depth > self.limits.maximum_depth || self.budget.nodes == self.limits.maximum_nodes
1294 {
1295 return Err(de::Error::custom(WORDFENCE_JSON_LIMIT_MARKER));
1296 }
1297 self.budget.nodes += 1;
1298 decoder.deserialize_any(WordfenceBoundedValueVisitor {
1299 depth: self.depth,
1300 limits: self.limits,
1301 budget: self.budget,
1302 })
1303 }
1304}
1305
1306struct WordfenceBoundedValueVisitor<'a> {
1307 depth: usize,
1308 limits: WordfenceJsonLimits,
1309 budget: &'a mut WordfenceJsonBudget,
1310}
1311
1312impl<'de> Visitor<'de> for WordfenceBoundedValueVisitor<'_> {
1313 type Value = Value;
1314
1315 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1316 formatter.write_str("bounded Wordfence Production JSON")
1317 }
1318
1319 fn visit_bool<E: de::Error>(self, value: bool) -> Result<Value, E> {
1320 Ok(Value::Bool(value))
1321 }
1322
1323 fn visit_unit<E: de::Error>(self) -> Result<Value, E> {
1324 Ok(Value::Null)
1325 }
1326
1327 fn visit_u64<E: de::Error>(self, value: u64) -> Result<Value, E> {
1328 Ok(Value::Number(Number::from(value)))
1329 }
1330
1331 fn visit_i64<E: de::Error>(self, value: i64) -> Result<Value, E> {
1332 Ok(Value::Number(Number::from(value)))
1333 }
1334
1335 fn visit_f64<E: de::Error>(self, value: f64) -> Result<Value, E> {
1336 Number::from_f64(value)
1337 .map(Value::Number)
1338 .ok_or_else(|| de::Error::custom("non-finite number"))
1339 }
1340
1341 fn visit_borrowed_str<E: de::Error>(self, value: &'de str) -> Result<Value, E> {
1342 self.bounded_string(value)
1343 }
1344
1345 fn visit_str<E: de::Error>(self, value: &str) -> Result<Value, E> {
1346 self.bounded_string(value)
1347 }
1348
1349 fn visit_string<E: de::Error>(self, value: String) -> Result<Value, E> {
1350 if value.len() > self.limits.maximum_string_bytes {
1351 Err(de::Error::custom(WORDFENCE_JSON_LIMIT_MARKER))
1352 } else {
1353 Ok(Value::String(value))
1354 }
1355 }
1356
1357 fn visit_seq<A: SeqAccess<'de>>(self, mut values: A) -> Result<Value, A::Error> {
1358 let mut array = Vec::new();
1359 loop {
1360 if array.len() == self.limits.maximum_array_length {
1361 if values.next_element::<de::IgnoredAny>()?.is_some() {
1362 return Err(de::Error::custom(WORDFENCE_JSON_LIMIT_MARKER));
1363 }
1364 break;
1365 }
1366 let Some(value) = values.next_element_seed(WordfenceBoundedValueSeed {
1367 depth: self.depth + 1,
1368 limits: self.limits,
1369 budget: &mut *self.budget,
1370 })?
1371 else {
1372 break;
1373 };
1374 array.push(value);
1375 }
1376 Ok(Value::Array(array))
1377 }
1378
1379 fn visit_map<A: MapAccess<'de>>(self, mut fields: A) -> Result<Value, A::Error> {
1380 let mut object = serde_json::Map::new();
1381 loop {
1382 if object.len() == self.limits.maximum_object_members {
1383 if fields
1384 .next_key_seed(WordfenceBoundedKeySeed {
1385 maximum_bytes: self.limits.maximum_key_bytes,
1386 })?
1387 .is_some()
1388 {
1389 return Err(de::Error::custom(WORDFENCE_JSON_LIMIT_MARKER));
1390 }
1391 break;
1392 }
1393 let Some(key) = fields.next_key_seed(WordfenceBoundedKeySeed {
1394 maximum_bytes: self.limits.maximum_key_bytes,
1395 })?
1396 else {
1397 break;
1398 };
1399 if object.contains_key(&key) {
1400 return Err(de::Error::custom(WORDFENCE_DUPLICATE_KEY_MARKER));
1401 }
1402 let value = fields.next_value_seed(WordfenceBoundedValueSeed {
1403 depth: self.depth + 1,
1404 limits: self.limits,
1405 budget: &mut *self.budget,
1406 })?;
1407 object.insert(key, value);
1408 }
1409 Ok(Value::Object(object))
1410 }
1411}
1412
1413impl WordfenceBoundedValueVisitor<'_> {
1414 fn bounded_string<E: de::Error>(&self, value: &str) -> Result<Value, E> {
1415 if value.len() > self.limits.maximum_string_bytes {
1416 Err(de::Error::custom(WORDFENCE_JSON_LIMIT_MARKER))
1417 } else {
1418 Ok(Value::String(value.to_owned()))
1419 }
1420 }
1421}
1422
1423struct WordfenceBoundedKeySeed {
1424 maximum_bytes: usize,
1425}
1426
1427impl<'de> DeserializeSeed<'de> for WordfenceBoundedKeySeed {
1428 type Value = String;
1429
1430 fn deserialize<D: de::Deserializer<'de>>(self, decoder: D) -> Result<String, D::Error> {
1431 decoder.deserialize_str(WordfenceBoundedKeyVisitor {
1432 maximum_bytes: self.maximum_bytes,
1433 })
1434 }
1435}
1436
1437struct WordfenceBoundedKeyVisitor {
1438 maximum_bytes: usize,
1439}
1440
1441impl Visitor<'_> for WordfenceBoundedKeyVisitor {
1442 type Value = String;
1443
1444 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1445 formatter.write_str("a bounded Wordfence Production object key")
1446 }
1447
1448 fn visit_borrowed_str<E: de::Error>(self, value: &str) -> Result<String, E> {
1449 self.visit_str(value)
1450 }
1451
1452 fn visit_str<E: de::Error>(self, value: &str) -> Result<String, E> {
1453 if value.len() > self.maximum_bytes {
1454 Err(de::Error::custom(WORDFENCE_JSON_LIMIT_MARKER))
1455 } else {
1456 Ok(value.to_owned())
1457 }
1458 }
1459
1460 fn visit_string<E: de::Error>(self, value: String) -> Result<String, E> {
1461 if value.len() > self.maximum_bytes {
1462 Err(de::Error::custom(WORDFENCE_JSON_LIMIT_MARKER))
1463 } else {
1464 Ok(value)
1465 }
1466 }
1467}
1468
1469#[derive(Deserialize)]
1470#[serde(deny_unknown_fields)]
1471struct ProductionRecordWire {
1472 id: String,
1473 title: String,
1474 software: Vec<SoftwareWire>,
1475 informational: bool,
1476 description: String,
1477 references: Vec<String>,
1478 cwe: Value,
1479 cvss: Value,
1480 cve: Value,
1481 cve_link: Value,
1482 researchers: Vec<String>,
1483 published: Value,
1484 updated: Value,
1485 copyrights: Value,
1486}
1487
1488#[derive(Deserialize)]
1489#[serde(deny_unknown_fields)]
1490struct SoftwareWire {
1491 #[serde(rename = "type")]
1492 kind: String,
1493 name: String,
1494 slug: String,
1495 affected_versions: BTreeMap<String, AffectedRangeWire>,
1496 patched: bool,
1497 patched_versions: Vec<String>,
1498 remediation: String,
1499}
1500
1501#[derive(Deserialize)]
1502#[serde(deny_unknown_fields)]
1503struct AffectedRangeWire {
1504 from_version: String,
1505 from_inclusive: bool,
1506 to_version: String,
1507 to_inclusive: bool,
1508}
1509
1510#[derive(Deserialize)]
1511#[serde(deny_unknown_fields)]
1512struct CweWire {
1513 id: u32,
1514 name: String,
1515 description: String,
1516}
1517
1518#[derive(Deserialize)]
1519#[serde(deny_unknown_fields)]
1520struct CvssWire {
1521 vector: String,
1522 score: Number,
1523 rating: String,
1524}
1525
1526#[derive(Deserialize)]
1527#[serde(deny_unknown_fields)]
1528struct NoticePartyWire {
1529 notice: String,
1530 license: String,
1531 license_url: String,
1532}
1533
1534fn decode_record(
1535 map_id: String,
1536 value: Value,
1537) -> Result<(WordfenceV3Record, Vec<WordfenceV3Notice>), WordfenceV3ProductionError> {
1538 let wire: ProductionRecordWire =
1539 serde_json::from_value(value).map_err(|_| WordfenceV3ProductionError::UnsupportedValue)?;
1540 validate_uuid(&wire.id)?;
1541 if wire.id != map_id {
1542 return Err(WordfenceV3ProductionError::ConflictingIdentity);
1543 }
1544 validate_text(&wire.title, MAX_WORDFENCE_V3_TITLE_BYTES, false)?;
1545 validate_text(&wire.description, MAX_WORDFENCE_V3_DESCRIPTION_BYTES, true)?;
1546 if wire.software.is_empty() || wire.software.len() > MAX_WORDFENCE_V3_SOFTWARE_PER_RECORD {
1547 return Err(WordfenceV3ProductionError::StructuralLimitExceeded);
1548 }
1549 validate_string_set(
1550 &wire.references,
1551 MAX_WORDFENCE_V3_REFERENCES,
1552 MAX_WORDFENCE_V3_REFERENCE_BYTES,
1553 validate_http_reference,
1554 )?;
1555 validate_string_sequence(
1556 &wire.researchers,
1557 MAX_WORDFENCE_V3_RESEARCHERS,
1558 MAX_WORDFENCE_V3_RESEARCHER_BYTES,
1559 |value| validate_text(value, MAX_WORDFENCE_V3_RESEARCHER_BYTES, false),
1560 )?;
1561
1562 let cwe = decode_nullable::<CweWire>(wire.cwe)?
1563 .map(validate_cwe)
1564 .transpose()?;
1565 let cvss = decode_nullable::<CvssWire>(wire.cvss)?
1566 .map(validate_cvss)
1567 .transpose()?;
1568 let cve = decode_nullable::<String>(wire.cve)?
1569 .map(|value| {
1570 validate_cve(&value)?;
1571 Ok(value)
1572 })
1573 .transpose()?;
1574 let cve_link = decode_nullable::<String>(wire.cve_link)?
1575 .map(|value| {
1576 validate_http_reference(&value)?;
1577 Ok(value)
1578 })
1579 .transpose()?;
1580 if cve_link.is_some() && cve.is_none() {
1581 return Err(WordfenceV3ProductionError::ConflictingIdentity);
1582 }
1583 let published = decode_nullable::<String>(wire.published)?
1584 .map(|value| {
1585 validate_timestamp(&value)?;
1586 Ok(value)
1587 })
1588 .transpose()?;
1589 let updated = decode_nullable::<String>(wire.updated)?
1590 .map(|value| {
1591 validate_timestamp(&value)?;
1592 Ok(value)
1593 })
1594 .transpose()?;
1595
1596 let mut software = Vec::new();
1597 let mut unresolved_software = Vec::new();
1598 for decoded in wire
1599 .software
1600 .into_iter()
1601 .map(|software| decode_software(&map_id, software))
1602 {
1603 match decoded? {
1604 DecodedSoftwareAssociation::Mapped(association) => software.push(association),
1605 DecodedSoftwareAssociation::Unresolved(association) => {
1606 unresolved_software.push(association);
1607 },
1608 }
1609 }
1610 software.sort_by(|left, right| left.key.cmp(&right.key));
1611 if software.windows(2).any(|pair| pair[0].key == pair[1].key) {
1612 return Err(WordfenceV3ProductionError::ConflictingIdentity);
1613 }
1614 unresolved_software.sort_by(|left, right| {
1615 left.source_component
1616 .cmp(&right.source_component)
1617 .then_with(|| left.upstream_id.cmp(&right.upstream_id))
1618 .then_with(|| {
1619 left.source_association_sha256
1620 .cmp(&right.source_association_sha256)
1621 })
1622 });
1623 if unresolved_software.windows(2).any(|pair| {
1624 pair[0].upstream_id == pair[1].upstream_id
1625 && pair[0].source_component == pair[1].source_component
1626 && pair[0].source_association_sha256 == pair[1].source_association_sha256
1627 }) {
1628 return Err(WordfenceV3ProductionError::ConflictingIdentity);
1629 }
1630
1631 let notices = decode_notices(wire.copyrights)?;
1632 if notices.iter().any(|notice| notice.party == "defiant")
1633 && !wire
1634 .references
1635 .iter()
1636 .any(|reference| is_official_wordfence_vulnerability_reference(reference))
1637 {
1638 return Err(WordfenceV3ProductionError::UnsupportedValue);
1639 }
1640 let mut notice_ids = notices
1641 .iter()
1642 .map(|notice| notice.id.clone())
1643 .collect::<Vec<_>>();
1644 notice_ids.sort();
1645
1646 let mut references = wire.references;
1647 references.sort();
1648 let mut researchers = wire.researchers;
1649 researchers.sort();
1650 Ok((
1651 WordfenceV3Record {
1652 upstream_id: map_id,
1653 title: wire.title,
1654 informational: wire.informational,
1655 description: wire.description,
1656 references,
1657 cwe,
1658 cvss,
1659 cve,
1660 cve_link,
1661 researchers,
1662 published,
1663 updated,
1664 notice_ids,
1665 software,
1666 unresolved_software,
1667 },
1668 notices,
1669 ))
1670}
1671
1672enum DecodedSoftwareAssociation {
1673 Mapped(WordfenceV3SoftwareAssociation),
1674 Unresolved(WordfenceV3UnresolvedSoftwareAssociation),
1675}
1676
1677fn decode_software(
1678 upstream_id: &str,
1679 wire: SoftwareWire,
1680) -> Result<DecodedSoftwareAssociation, WordfenceV3ProductionError> {
1681 let kind = match wire.kind.as_str() {
1682 "core" => WordPressComponentKind::Core,
1683 "plugin" => WordPressComponentKind::Plugin,
1684 "theme" => WordPressComponentKind::Theme,
1685 _ => return Err(WordfenceV3ProductionError::UnsupportedValue),
1686 };
1687 validate_text(&wire.name, MAX_WORDFENCE_V3_NAME_BYTES, false)?;
1688 validate_source_slug(&wire.slug)?;
1689 let source_component = WordfenceV3SourceComponentIdentity {
1690 kind,
1691 slug: wire.slug,
1692 };
1693 let mapped = WordPressComponentIdentity::new(kind, source_component.slug.clone())
1694 .map(|component| (component, WordfenceV3IdentityMapping::Exact))
1695 .or_else(|_| {
1696 let folded = source_component.slug.to_ascii_lowercase();
1697 WordPressComponentIdentity::new(kind, folded).map(|component| {
1698 (
1699 component,
1700 WordfenceV3IdentityMapping::AsciiCaseFoldCandidate,
1701 )
1702 })
1703 })
1704 .ok();
1705 if wire.affected_versions.len() > MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION
1706 || wire.patched_versions.len() > MAX_WORDFENCE_V3_PATCHED_VERSIONS
1707 {
1708 return Err(WordfenceV3ProductionError::StructuralLimitExceeded);
1709 }
1710 let mut affected_ranges = wire
1711 .affected_versions
1712 .into_iter()
1713 .map(|(label, range)| decode_range(label, range))
1714 .collect::<Result<Vec<_>, _>>()?;
1715 affected_ranges.sort();
1716 let mut patched_versions = wire.patched_versions;
1717 for version in &patched_versions {
1718 validate_version(version, false)?;
1719 }
1720 patched_versions.sort();
1721 if patched_versions.windows(2).any(|pair| pair[0] == pair[1]) {
1722 return Err(WordfenceV3ProductionError::ConflictingIdentity);
1723 }
1724 validate_text(&wire.remediation, MAX_WORDFENCE_V3_REMEDIATION_BYTES, true)?;
1725 let source_association_sha256 = source_association_digest(
1726 &source_component,
1727 &wire.name,
1728 &affected_ranges,
1729 wire.patched,
1730 &patched_versions,
1731 &wire.remediation,
1732 );
1733 let upstream_id = upstream_id.to_owned();
1734 Ok(if let Some((component, identity_mapping)) = mapped {
1735 DecodedSoftwareAssociation::Mapped(WordfenceV3SoftwareAssociation {
1736 key: WordfenceV3AssociationKey {
1737 upstream_id,
1738 component,
1739 source_component,
1740 source_association_sha256,
1741 },
1742 identity_mapping,
1743 identity_collision_raw_count: None,
1744 display_name: wire.name,
1745 affected_ranges,
1746 patched: wire.patched,
1747 patched_versions,
1748 remediation: wire.remediation,
1749 })
1750 } else {
1751 DecodedSoftwareAssociation::Unresolved(WordfenceV3UnresolvedSoftwareAssociation {
1752 upstream_id,
1753 source_component,
1754 source_association_sha256,
1755 display_name: wire.name,
1756 affected_ranges,
1757 patched: wire.patched,
1758 patched_versions,
1759 remediation: wire.remediation,
1760 })
1761 })
1762}
1763
1764fn decode_range(
1765 label: String,
1766 wire: AffectedRangeWire,
1767) -> Result<WordfenceV3AffectedRange, WordfenceV3ProductionError> {
1768 validate_text(&label, MAX_WORDFENCE_V3_RANGE_LABEL_BYTES, false)?;
1769 Ok(WordfenceV3AffectedRange {
1770 label,
1771 from: WordfenceV3RangeEndpoint {
1772 value: decode_range_value(wire.from_version)?,
1773 inclusive: wire.from_inclusive,
1774 },
1775 to: WordfenceV3RangeEndpoint {
1776 value: decode_range_value(wire.to_version)?,
1777 inclusive: wire.to_inclusive,
1778 },
1779 })
1780}
1781
1782fn decode_range_value(value: String) -> Result<WordfenceV3RangeValue, WordfenceV3ProductionError> {
1783 if value == "*" {
1784 Ok(WordfenceV3RangeValue::Any)
1785 } else {
1786 validate_version(&value, false)?;
1787 Ok(WordfenceV3RangeValue::Declared(value))
1788 }
1789}
1790
1791fn decode_notices(value: Value) -> Result<Vec<WordfenceV3Notice>, WordfenceV3ProductionError> {
1792 if value.is_null() {
1793 return Ok(Vec::new());
1794 }
1795 let mut object = value
1796 .as_object()
1797 .cloned()
1798 .ok_or(WordfenceV3ProductionError::UnsupportedValue)?;
1799 let message = object
1800 .remove("message")
1801 .and_then(|value| value.as_str().map(str::to_owned))
1802 .ok_or(WordfenceV3ProductionError::UnsupportedValue)?;
1803 validate_text(&message, MAX_WORDFENCE_V3_NOTICE_TEXT_BYTES, true)?;
1804 if object.is_empty() || object.len() > MAX_WORDFENCE_V3_NOTICE_PARTIES {
1805 return Err(WordfenceV3ProductionError::StructuralLimitExceeded);
1806 }
1807 let mut notices = Vec::with_capacity(object.len());
1808 for (party, value) in object {
1809 validate_identifier(&party, 128)?;
1810 let wire: NoticePartyWire = serde_json::from_value(value)
1811 .map_err(|_| WordfenceV3ProductionError::UnsupportedValue)?;
1812 validate_text(&wire.notice, MAX_WORDFENCE_V3_NOTICE_TEXT_BYTES, true)?;
1813 validate_text(&wire.license, MAX_WORDFENCE_V3_NOTICE_TEXT_BYTES, true)?;
1814 validate_http_reference(&wire.license_url)?;
1815 let id = notice_id(
1816 &message,
1817 &party,
1818 &wire.notice,
1819 &wire.license,
1820 &wire.license_url,
1821 );
1822 notices.push(WordfenceV3Notice {
1823 id,
1824 message: message.clone(),
1825 party,
1826 notice: wire.notice,
1827 license: wire.license,
1828 license_url: wire.license_url,
1829 });
1830 }
1831 notices.sort();
1832 Ok(notices)
1833}
1834
1835fn decode_nullable<T: for<'de> Deserialize<'de>>(
1836 value: Value,
1837) -> Result<Option<T>, WordfenceV3ProductionError> {
1838 if value.is_null() {
1839 Ok(None)
1840 } else {
1841 serde_json::from_value(value)
1842 .map(Some)
1843 .map_err(|_| WordfenceV3ProductionError::UnsupportedValue)
1844 }
1845}
1846
1847fn validate_cwe(wire: CweWire) -> Result<WordfenceV3Cwe, WordfenceV3ProductionError> {
1848 if wire.id == 0 {
1849 return Err(WordfenceV3ProductionError::UnsupportedValue);
1850 }
1851 validate_text(&wire.name, MAX_WORDFENCE_V3_NAME_BYTES, false)?;
1852 validate_text(&wire.description, MAX_WORDFENCE_V3_DESCRIPTION_BYTES, true)?;
1853 Ok(WordfenceV3Cwe {
1854 id: wire.id,
1855 name: wire.name,
1856 description: wire.description,
1857 })
1858}
1859
1860fn validate_cvss(wire: CvssWire) -> Result<WordfenceV3Cvss, WordfenceV3ProductionError> {
1861 validate_text(&wire.vector, MAX_WORDFENCE_V3_CVSS_VECTOR_BYTES, false)?;
1862 if !wire.vector.starts_with("CVSS:3.0/") && !wire.vector.starts_with("CVSS:3.1/") {
1863 return Err(WordfenceV3ProductionError::UnsupportedValue);
1864 }
1865 let score = wire.score.to_string();
1869 let mut score_parts = score.split('.');
1870 let integer = score_parts.next().unwrap_or_default();
1871 let fraction = score_parts.next();
1872 if score_parts.next().is_some()
1873 || integer.is_empty()
1874 || !integer.bytes().all(|byte| byte.is_ascii_digit())
1875 || (integer.len() > 1 && integer.starts_with('0'))
1876 || fraction.is_some_and(|value| {
1877 value.len() != 1 || !value.bytes().all(|byte| byte.is_ascii_digit())
1878 })
1879 {
1880 return Err(WordfenceV3ProductionError::UnsupportedValue);
1881 }
1882 match integer.parse::<u8>() {
1883 Ok(0..=9) => {},
1884 Ok(10) if fraction.is_none_or(|value| value == "0") => {},
1885 _ => return Err(WordfenceV3ProductionError::UnsupportedValue),
1886 }
1887 let rating = match wire.rating.as_str() {
1888 "None" => WordfenceV3CvssRating::None,
1889 "Low" => WordfenceV3CvssRating::Low,
1890 "Medium" => WordfenceV3CvssRating::Medium,
1891 "High" => WordfenceV3CvssRating::High,
1892 "Critical" => WordfenceV3CvssRating::Critical,
1893 _ => return Err(WordfenceV3ProductionError::UnsupportedValue),
1894 };
1895 Ok(WordfenceV3Cvss {
1896 vector: wire.vector,
1897 score,
1898 rating,
1899 })
1900}
1901
1902fn validate_uuid(value: &str) -> Result<(), WordfenceV3ProductionError> {
1903 if value.len() != 36
1904 || !value.bytes().enumerate().all(|(index, byte)| match index {
1905 8 | 13 | 18 | 23 => byte == b'-',
1906 _ => byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte),
1907 })
1908 {
1909 return Err(WordfenceV3ProductionError::UnsupportedValue);
1910 }
1911 Ok(())
1912}
1913
1914fn validate_cve(value: &str) -> Result<(), WordfenceV3ProductionError> {
1915 if value.len() > MAX_WORDFENCE_V3_CVE_BYTES {
1916 return Err(WordfenceV3ProductionError::UnsupportedValue);
1917 }
1918 let mut parts = value.split('-');
1919 if parts.next() != Some("CVE")
1920 || parts
1921 .next()
1922 .is_none_or(|year| year.len() != 4 || !year.bytes().all(|b| b.is_ascii_digit()))
1923 || parts
1924 .next()
1925 .is_none_or(|id| id.len() < 4 || !id.bytes().all(|b| b.is_ascii_digit()))
1926 || parts.next().is_some()
1927 {
1928 return Err(WordfenceV3ProductionError::UnsupportedValue);
1929 }
1930 Ok(())
1931}
1932
1933fn validate_timestamp(value: &str) -> Result<(), WordfenceV3ProductionError> {
1934 if value.len() != 19
1935 || value.as_bytes()[4] != b'-'
1936 || value.as_bytes()[7] != b'-'
1937 || value.as_bytes()[10] != b' '
1938 || value.as_bytes()[13] != b':'
1939 || value.as_bytes()[16] != b':'
1940 || !value
1941 .bytes()
1942 .enumerate()
1943 .all(|(index, byte)| matches!(index, 4 | 7 | 10 | 13 | 16) || byte.is_ascii_digit())
1944 {
1945 return Err(WordfenceV3ProductionError::UnsupportedValue);
1946 }
1947 let month = parse_two_digits(value, 5)?;
1948 let day = parse_two_digits(value, 8)?;
1949 let hour = parse_two_digits(value, 11)?;
1950 let minute = parse_two_digits(value, 14)?;
1951 let second = parse_two_digits(value, 17)?;
1952 let year = value[..4]
1953 .parse::<u16>()
1954 .map_err(|_| WordfenceV3ProductionError::UnsupportedValue)?;
1955 let days_in_month = match month {
1956 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1957 4 | 6 | 9 | 11 => 30,
1958 2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29,
1959 2 => 28,
1960 _ => 0,
1961 };
1962 if day == 0 || day > days_in_month || hour > 23 || minute > 59 || second > 59 {
1963 return Err(WordfenceV3ProductionError::UnsupportedValue);
1964 }
1965 Ok(())
1966}
1967
1968fn parse_two_digits(value: &str, start: usize) -> Result<u8, WordfenceV3ProductionError> {
1969 value[start..start + 2]
1970 .parse()
1971 .map_err(|_| WordfenceV3ProductionError::UnsupportedValue)
1972}
1973
1974fn validate_version(value: &str, allow_wildcard: bool) -> Result<(), WordfenceV3ProductionError> {
1975 if value.is_empty()
1976 || value.len() > MAX_WORDFENCE_V3_VERSION_BYTES
1977 || (!allow_wildcard && value == "*")
1978 || value.chars().any(char::is_control)
1979 || value.chars().next().is_some_and(char::is_whitespace)
1980 || value.chars().next_back().is_some_and(char::is_whitespace)
1981 {
1982 return Err(WordfenceV3ProductionError::UnsupportedValue);
1983 }
1984 Ok(())
1985}
1986
1987fn validate_identifier(value: &str, maximum: usize) -> Result<(), WordfenceV3ProductionError> {
1988 if value.is_empty()
1989 || value.len() > maximum
1990 || !value.is_ascii()
1991 || !value
1992 .bytes()
1993 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1994 {
1995 return Err(WordfenceV3ProductionError::UnsupportedValue);
1996 }
1997 Ok(())
1998}
1999
2000fn validate_source_slug(value: &str) -> Result<(), WordfenceV3ProductionError> {
2001 if value.is_empty()
2006 || value.len() > MAX_WORDFENCE_V3_SOURCE_SLUG_BYTES
2007 || !value.is_ascii()
2008 || value.bytes().any(|byte| byte.is_ascii_control())
2009 || !value.bytes().any(|byte| byte.is_ascii_alphanumeric())
2010 {
2011 return Err(WordfenceV3ProductionError::UnsupportedValue);
2012 }
2013 Ok(())
2014}
2015
2016fn validate_text(
2017 value: &str,
2018 maximum: usize,
2019 allow_empty: bool,
2020) -> Result<(), WordfenceV3ProductionError> {
2021 if (!allow_empty && value.is_empty())
2022 || value.len() > maximum
2023 || value
2024 .chars()
2025 .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
2026 {
2027 return Err(WordfenceV3ProductionError::UnsupportedValue);
2028 }
2029 Ok(())
2030}
2031
2032fn validate_http_reference(value: &str) -> Result<(), WordfenceV3ProductionError> {
2033 validate_text(value, MAX_WORDFENCE_V3_REFERENCE_BYTES, false)?;
2034 let parsed = Url::parse(value).map_err(|_| WordfenceV3ProductionError::UnsupportedValue)?;
2035 if !matches!(parsed.scheme(), "http" | "https")
2036 || parsed.host().is_none()
2037 || !parsed.username().is_empty()
2038 || parsed.password().is_some()
2039 {
2040 return Err(WordfenceV3ProductionError::UnsupportedValue);
2041 }
2042 Ok(())
2043}
2044
2045fn is_official_wordfence_vulnerability_reference(value: &str) -> bool {
2046 const PREFIX: &str = "/threat-intel/vulnerabilities/";
2047 Url::parse(value).ok().is_some_and(|url| {
2048 matches!(url.scheme(), "http" | "https")
2049 && url.host_str() == Some("www.wordfence.com")
2050 && url.username().is_empty()
2051 && url.password().is_none()
2052 && url.port().is_none()
2053 && url.as_str() == value
2054 && url.path().starts_with(PREFIX)
2055 && url.path().len() > PREFIX.len()
2056 && has_supported_wordfence_reference_query(&url)
2057 && url.fragment().is_none()
2058 })
2059}
2060
2061fn has_supported_wordfence_reference_query(url: &Url) -> bool {
2062 const MAX_SOURCE_VALUE_BYTES: usize = 64;
2063 match url.query() {
2066 None => true,
2067 Some(query) => query.strip_prefix("source=").is_some_and(|value| {
2068 !value.is_empty()
2069 && value.len() <= MAX_SOURCE_VALUE_BYTES
2070 && value.bytes().all(|byte| {
2071 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
2072 })
2073 }),
2074 }
2075}
2076
2077fn validate_string_set(
2078 values: &[String],
2079 maximum_count: usize,
2080 maximum_bytes: usize,
2081 validate: impl Fn(&str) -> Result<(), WordfenceV3ProductionError>,
2082) -> Result<(), WordfenceV3ProductionError> {
2083 if values.len() > maximum_count {
2084 return Err(WordfenceV3ProductionError::StructuralLimitExceeded);
2085 }
2086 let mut seen = BTreeSet::new();
2087 for value in values {
2088 if value.len() > maximum_bytes {
2089 return Err(WordfenceV3ProductionError::UnsupportedValue);
2090 }
2091 validate(value)?;
2092 if !seen.insert(value) {
2093 return Err(WordfenceV3ProductionError::ConflictingIdentity);
2094 }
2095 }
2096 Ok(())
2097}
2098
2099fn validate_string_sequence(
2100 values: &[String],
2101 maximum_count: usize,
2102 maximum_bytes: usize,
2103 validate: impl Fn(&str) -> Result<(), WordfenceV3ProductionError>,
2104) -> Result<(), WordfenceV3ProductionError> {
2105 if values.len() > maximum_count {
2106 return Err(WordfenceV3ProductionError::StructuralLimitExceeded);
2107 }
2108 for value in values {
2109 if value.len() > maximum_bytes {
2110 return Err(WordfenceV3ProductionError::UnsupportedValue);
2111 }
2112 validate(value)?;
2113 }
2114 Ok(())
2115}
2116
2117fn notice_id(message: &str, party: &str, notice: &str, license: &str, url: &str) -> String {
2118 let mut digest = Sha256::new();
2119 digest.update(b"termivar.wordfence-v3.notice/v1\0");
2120 for value in [message, party, notice, license, url] {
2121 frame(&mut digest, value.as_bytes());
2122 }
2123 let digest = digest.finalize();
2124 format!("wordfence-notice-sha256:{digest:x}")
2125}
2126
2127fn semantic_digest(
2128 records: &[WordfenceV3Record],
2129 notices: &[WordfenceV3Notice],
2130 identity_counts: &WordfenceV3IdentityCounts,
2131 requires_expanded_semantics: bool,
2132) -> [u8; 32] {
2133 if !identity_counts.requires_source_identity_mapping() && !requires_expanded_semantics {
2134 return semantic_digest_v1(records, notices);
2135 }
2136
2137 let mut digest = Sha256::new();
2138 digest.update(b"termivar.wordfence-v3-production.semantic/v2\0");
2139 frame(&mut digest, WORDFENCE_V3_SOURCE_NAMESPACE.as_bytes());
2140 let mapping_revision = if identity_counts.requires_source_identity_mapping() {
2141 WORDFENCE_V3_MAPPING_REVISION_V2
2142 } else {
2143 WORDFENCE_V3_MAPPING_REVISION
2144 };
2145 frame(&mut digest, mapping_revision.as_bytes());
2146 frame(&mut digest, WORDFENCE_V3_IDENTITY_MAPPING_POLICY.as_bytes());
2147 for value in [
2148 identity_counts.exact,
2149 identity_counts.candidate,
2150 identity_counts.ambiguous,
2151 identity_counts.unresolved,
2152 ] {
2153 frame_usize(&mut digest, value);
2154 }
2155 frame_usize(&mut digest, records.len());
2156 for record in records {
2157 hash_record_v2(&mut digest, record);
2158 }
2159 hash_notices(&mut digest, notices);
2160 digest.finalize().into()
2161}
2162
2163fn semantic_digest_v1(records: &[WordfenceV3Record], notices: &[WordfenceV3Notice]) -> [u8; 32] {
2164 let mut digest = Sha256::new();
2165 digest.update(b"termivar.wordfence-v3-production.semantic/v1\0");
2166 frame(&mut digest, WORDFENCE_V3_SOURCE_NAMESPACE.as_bytes());
2167 frame(&mut digest, WORDFENCE_V3_MAPPING_REVISION.as_bytes());
2168 frame_usize(&mut digest, records.len());
2169 for record in records {
2170 hash_record_v1(&mut digest, record);
2171 }
2172 hash_notices(&mut digest, notices);
2173 digest.finalize().into()
2174}
2175
2176fn hash_notices(digest: &mut Sha256, notices: &[WordfenceV3Notice]) {
2177 frame_usize(digest, notices.len());
2178 for notice in notices {
2179 for value in [
2180 notice.id.as_str(),
2181 notice.message.as_str(),
2182 notice.party.as_str(),
2183 notice.notice.as_str(),
2184 notice.license.as_str(),
2185 notice.license_url.as_str(),
2186 ] {
2187 frame(digest, value.as_bytes());
2188 }
2189 }
2190}
2191
2192fn hash_record_v1(digest: &mut Sha256, record: &WordfenceV3Record) {
2193 hash_record_common(digest, record);
2194 frame_usize(digest, record.software.len());
2195 for association in &record.software {
2196 hash_association_common(digest, association);
2197 }
2198}
2199
2200fn hash_record_v2(digest: &mut Sha256, record: &WordfenceV3Record) {
2201 hash_record_common(digest, record);
2202 frame_usize(digest, record.software.len());
2203 for association in &record.software {
2204 digest.update([association.source_component().kind() as u8]);
2205 frame(digest, association.source_component().slug().as_bytes());
2206 frame(digest, association.identity_mapping().as_str().as_bytes());
2207 match association.identity_collision_raw_count() {
2208 Some(count) => {
2209 digest.update([1]);
2210 frame_usize(digest, count);
2211 },
2212 None => digest.update([0]),
2213 }
2214 hash_association_common(digest, association);
2215 }
2216 frame_usize(digest, record.unresolved_software.len());
2217 for association in &record.unresolved_software {
2218 digest.update([association.source_component.kind as u8]);
2219 frame(digest, association.source_component.slug.as_bytes());
2220 frame(digest, association.display_name.as_bytes());
2221 hash_association_values(
2222 digest,
2223 &association.affected_ranges,
2224 association.patched,
2225 &association.patched_versions,
2226 &association.remediation,
2227 );
2228 }
2229}
2230
2231fn hash_record_common(digest: &mut Sha256, record: &WordfenceV3Record) {
2232 frame(digest, record.upstream_id.as_bytes());
2233 frame(digest, record.title.as_bytes());
2234 digest.update([u8::from(record.informational)]);
2235 frame(digest, record.description.as_bytes());
2236 hash_strings(digest, &record.references);
2237 match &record.cwe {
2238 Some(cwe) => {
2239 digest.update([1]);
2240 digest.update(cwe.id.to_be_bytes());
2241 frame(digest, cwe.name.as_bytes());
2242 frame(digest, cwe.description.as_bytes());
2243 },
2244 None => digest.update([0]),
2245 }
2246 match &record.cvss {
2247 Some(cvss) => {
2248 digest.update([1]);
2249 frame(digest, cvss.vector.as_bytes());
2250 frame(digest, cvss.score.as_bytes());
2251 digest.update([cvss.rating as u8]);
2252 },
2253 None => digest.update([0]),
2254 }
2255 hash_optional_string(digest, record.cve.as_deref());
2256 hash_optional_string(digest, record.cve_link.as_deref());
2257 hash_strings(digest, &record.researchers);
2258 hash_optional_string(digest, record.published.as_deref());
2259 hash_optional_string(digest, record.updated.as_deref());
2260 hash_strings(digest, &record.notice_ids);
2261}
2262
2263fn hash_association_common(digest: &mut Sha256, association: &WordfenceV3SoftwareAssociation) {
2264 digest.update([association.component().kind() as u8]);
2265 frame(digest, association.component().slug().as_bytes());
2266 frame(digest, association.display_name.as_bytes());
2267 hash_association_values(
2268 digest,
2269 &association.affected_ranges,
2270 association.patched,
2271 &association.patched_versions,
2272 &association.remediation,
2273 );
2274}
2275
2276fn source_association_digest(
2277 source_component: &WordfenceV3SourceComponentIdentity,
2278 display_name: &str,
2279 affected_ranges: &[WordfenceV3AffectedRange],
2280 patched: bool,
2281 patched_versions: &[String],
2282 remediation: &str,
2283) -> [u8; 32] {
2284 let mut digest = Sha256::new();
2285 digest.update(b"termivar.wordfence-v3.source-association/v1\0");
2286 digest.update([source_component.kind as u8]);
2287 frame(&mut digest, source_component.slug.as_bytes());
2288 frame(&mut digest, display_name.as_bytes());
2289 hash_association_values(
2290 &mut digest,
2291 affected_ranges,
2292 patched,
2293 patched_versions,
2294 remediation,
2295 );
2296 digest.finalize().into()
2297}
2298
2299fn hash_association_values(
2300 digest: &mut Sha256,
2301 affected_ranges: &[WordfenceV3AffectedRange],
2302 patched: bool,
2303 patched_versions: &[String],
2304 remediation: &str,
2305) {
2306 frame_usize(digest, affected_ranges.len());
2307 for range in affected_ranges {
2308 frame(digest, range.label.as_bytes());
2309 frame(digest, range.from.value.declared().as_bytes());
2310 digest.update([u8::from(range.from.inclusive)]);
2311 frame(digest, range.to.value.declared().as_bytes());
2312 digest.update([u8::from(range.to.inclusive)]);
2313 }
2314 digest.update([u8::from(patched)]);
2315 hash_strings(digest, patched_versions);
2316 frame(digest, remediation.as_bytes());
2317}
2318
2319fn hash_strings(digest: &mut Sha256, values: &[String]) {
2320 frame_usize(digest, values.len());
2321 for value in values {
2322 frame(digest, value.as_bytes());
2323 }
2324}
2325
2326fn hash_optional_string(digest: &mut Sha256, value: Option<&str>) {
2327 if let Some(value) = value {
2328 digest.update([1]);
2329 frame(digest, value.as_bytes());
2330 } else {
2331 digest.update([0]);
2332 }
2333}
2334
2335fn frame(digest: &mut Sha256, value: &[u8]) {
2336 digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
2337 digest.update(value);
2338}
2339
2340fn frame_usize(digest: &mut Sha256, value: usize) {
2341 digest.update(u64::try_from(value).unwrap_or(u64::MAX).to_be_bytes());
2342}
2343
2344fn retained_import_bytes(
2345 records: &[WordfenceV3Record],
2346 records_capacity: usize,
2347 notices: &[WordfenceV3Notice],
2348 notices_capacity: usize,
2349 component_index: &ComponentAssociationIndex,
2350 maximum: usize,
2351) -> Result<usize, WordfenceV3ProductionError> {
2352 let mut retained = RetainedBudget::with_base(
2353 maximum,
2354 retained_records_and_notices_bytes(
2355 records,
2356 records_capacity,
2357 notices,
2358 notices_capacity,
2359 maximum,
2360 )?,
2361 )?;
2362 account_component_index(&mut retained, component_index)?;
2363 Ok(retained.total())
2364}
2365
2366fn retained_records_and_notices_bytes(
2367 records: &[WordfenceV3Record],
2368 records_capacity: usize,
2369 notices: &[WordfenceV3Notice],
2370 notices_capacity: usize,
2371 maximum: usize,
2372) -> Result<usize, WordfenceV3ProductionError> {
2373 let mut retained =
2374 RetainedBudget::with_base(maximum, size_of::<WordfenceV3ProductionImport>())?;
2375 retained.add(
2376 records_capacity
2377 .checked_mul(size_of::<WordfenceV3Record>())
2378 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2379 )?;
2380 for record in records {
2381 retained.add(record_dynamic_bytes(record)?)?;
2382 }
2383 retained.add(
2384 notices_capacity
2385 .checked_mul(size_of::<WordfenceV3Notice>())
2386 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2387 )?;
2388 for notice in notices {
2389 retained.add(notice_dynamic_bytes(notice)?)?;
2390 }
2391 Ok(retained.total())
2392}
2393
2394fn account_component_index(
2395 retained: &mut RetainedBudget,
2396 component_index: &ComponentAssociationIndex,
2397) -> Result<(), WordfenceV3ProductionError> {
2398 for (component, coordinates) in component_index {
2399 retained.add_btree_entry(
2400 size_of::<WordPressComponentIdentity>()
2401 .checked_add(size_of::<Vec<AssociationCoordinate>>())
2402 .and_then(|bytes| bytes.checked_add(component.slug.capacity()))
2403 .and_then(|bytes| {
2404 bytes.checked_add(
2405 coordinates
2406 .capacity()
2407 .checked_mul(size_of::<AssociationCoordinate>())?,
2408 )
2409 })
2410 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2411 )?;
2412 }
2413 Ok(())
2414}
2415
2416fn record_dynamic_bytes(record: &WordfenceV3Record) -> Result<usize, WordfenceV3ProductionError> {
2417 let mut retained = RetainedBudget::with_base(usize::MAX, 0)?;
2418 for value in [&record.upstream_id, &record.title, &record.description] {
2419 retained.add(value.capacity())?;
2420 }
2421 for value in [
2422 record.cve.as_ref(),
2423 record.cve_link.as_ref(),
2424 record.published.as_ref(),
2425 record.updated.as_ref(),
2426 ]
2427 .into_iter()
2428 .flatten()
2429 {
2430 retained.add(value.capacity())?;
2431 }
2432 account_string_vec(
2433 &mut retained,
2434 &record.references,
2435 record.references.capacity(),
2436 )?;
2437 account_string_vec(
2438 &mut retained,
2439 &record.researchers,
2440 record.researchers.capacity(),
2441 )?;
2442 account_string_vec(
2443 &mut retained,
2444 &record.notice_ids,
2445 record.notice_ids.capacity(),
2446 )?;
2447 if let Some(cwe) = &record.cwe {
2448 retained.add(cwe.name.capacity())?;
2449 retained.add(cwe.description.capacity())?;
2450 }
2451 if let Some(cvss) = &record.cvss {
2452 retained.add(cvss.vector.capacity())?;
2453 retained.add(cvss.score.capacity())?;
2454 }
2455 retained.add(
2456 record
2457 .software
2458 .capacity()
2459 .checked_mul(size_of::<WordfenceV3SoftwareAssociation>())
2460 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2461 )?;
2462 for association in &record.software {
2463 retained.add(association.key.upstream_id.capacity())?;
2464 retained.add(association.key.component.slug.capacity())?;
2465 retained.add(association.key.source_component.slug.capacity())?;
2466 retained.add(association.display_name.capacity())?;
2467 retained.add(association.remediation.capacity())?;
2468 account_string_vec(
2469 &mut retained,
2470 &association.patched_versions,
2471 association.patched_versions.capacity(),
2472 )?;
2473 retained.add(
2474 association
2475 .affected_ranges
2476 .capacity()
2477 .checked_mul(size_of::<WordfenceV3AffectedRange>())
2478 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2479 )?;
2480 for range in &association.affected_ranges {
2481 retained.add(range.label.capacity())?;
2482 if let WordfenceV3RangeValue::Declared(value) = &range.from.value {
2483 retained.add(value.capacity())?;
2484 }
2485 if let WordfenceV3RangeValue::Declared(value) = &range.to.value {
2486 retained.add(value.capacity())?;
2487 }
2488 }
2489 }
2490 retained.add(
2491 record
2492 .unresolved_software
2493 .capacity()
2494 .checked_mul(size_of::<WordfenceV3UnresolvedSoftwareAssociation>())
2495 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2496 )?;
2497 for association in &record.unresolved_software {
2498 retained.add(association.upstream_id.capacity())?;
2499 retained.add(association.source_component.slug.capacity())?;
2500 retained.add(association.display_name.capacity())?;
2501 retained.add(association.remediation.capacity())?;
2502 account_string_vec(
2503 &mut retained,
2504 &association.patched_versions,
2505 association.patched_versions.capacity(),
2506 )?;
2507 retained.add(
2508 association
2509 .affected_ranges
2510 .capacity()
2511 .checked_mul(size_of::<WordfenceV3AffectedRange>())
2512 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2513 )?;
2514 for range in &association.affected_ranges {
2515 retained.add(range.label.capacity())?;
2516 if let WordfenceV3RangeValue::Declared(value) = &range.from.value {
2517 retained.add(value.capacity())?;
2518 }
2519 if let WordfenceV3RangeValue::Declared(value) = &range.to.value {
2520 retained.add(value.capacity())?;
2521 }
2522 }
2523 }
2524 Ok(retained.total())
2525}
2526
2527fn notice_dynamic_bytes(notice: &WordfenceV3Notice) -> Result<usize, WordfenceV3ProductionError> {
2528 let mut retained = RetainedBudget::with_base(usize::MAX, 0)?;
2529 for value in [
2530 ¬ice.id,
2531 ¬ice.message,
2532 ¬ice.party,
2533 ¬ice.notice,
2534 ¬ice.license,
2535 ¬ice.license_url,
2536 ] {
2537 retained.add(value.capacity())?;
2538 }
2539 Ok(retained.total())
2540}
2541
2542fn account_string_vec(
2543 retained: &mut RetainedBudget,
2544 values: &[String],
2545 capacity: usize,
2546) -> Result<(), WordfenceV3ProductionError> {
2547 retained.add(
2548 capacity
2549 .checked_mul(size_of::<String>())
2550 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2551 )?;
2552 for value in values {
2553 retained.add(value.capacity())?;
2554 }
2555 Ok(())
2556}
2557
2558fn reserve_one_with_retained_accounting<T>(
2559 values: &mut Vec<T>,
2560 retained: &mut RetainedBudget,
2561) -> Result<(), WordfenceV3ProductionError> {
2562 if values.len() < values.capacity() {
2563 return Ok(());
2564 }
2565 let required = values
2566 .len()
2567 .checked_add(1)
2568 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?;
2569 let planned_capacity = values.capacity().saturating_mul(2).max(4).max(required);
2570 reserve_exact_capacity_with_retained_accounting(values, planned_capacity, retained)
2571}
2572
2573fn reserve_exact_capacity_with_retained_accounting<T>(
2574 values: &mut Vec<T>,
2575 requested_capacity: usize,
2576 retained: &mut RetainedBudget,
2577) -> Result<(), WordfenceV3ProductionError> {
2578 let previous_capacity = values.capacity();
2579 if requested_capacity <= previous_capacity {
2580 return Ok(());
2581 }
2582 let planned_delta = requested_capacity
2583 .checked_sub(previous_capacity)
2584 .and_then(|capacity| capacity.checked_mul(size_of::<T>()))
2585 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?;
2586 retained.add(planned_delta)?;
2590 values
2591 .try_reserve_exact(
2592 requested_capacity
2593 .checked_sub(values.len())
2594 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2595 )
2596 .map_err(|_| WordfenceV3ProductionError::RetainedDataTooLarge)?;
2597 let unplanned_capacity = values.capacity().saturating_sub(requested_capacity);
2598 retained.add(
2599 unplanned_capacity
2600 .checked_mul(size_of::<T>())
2601 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2602 )
2603}
2604
2605struct RetainedBudget {
2606 total: usize,
2607 maximum: usize,
2608}
2609
2610impl RetainedBudget {
2611 fn with_base(maximum: usize, base: usize) -> Result<Self, WordfenceV3ProductionError> {
2612 if base > maximum {
2613 return Err(WordfenceV3ProductionError::RetainedDataTooLarge);
2614 }
2615 Ok(Self {
2616 total: base,
2617 maximum,
2618 })
2619 }
2620
2621 fn add(&mut self, bytes: usize) -> Result<(), WordfenceV3ProductionError> {
2622 self.total = self
2623 .total
2624 .checked_add(bytes)
2625 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?;
2626 if self.total > self.maximum {
2627 return Err(WordfenceV3ProductionError::RetainedDataTooLarge);
2628 }
2629 Ok(())
2630 }
2631
2632 fn add_btree_entry(&mut self, stored_bytes: usize) -> Result<(), WordfenceV3ProductionError> {
2633 self.add(
2634 stored_bytes
2635 .checked_add(CONSERVATIVE_BTREE_NODE_BYTES_PER_ENTRY)
2636 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2637 )
2638 }
2639
2640 fn add_notice_map_entry(
2641 &mut self,
2642 map_key: &String,
2643 notice: &WordfenceV3Notice,
2644 ) -> Result<(), WordfenceV3ProductionError> {
2645 self.add_btree_entry(
2646 size_of::<String>()
2647 .checked_add(size_of::<WordfenceV3Notice>())
2648 .and_then(|bytes| bytes.checked_add(map_key.capacity()))
2649 .and_then(|bytes| bytes.checked_add(notice_dynamic_bytes(notice).ok()?))
2650 .ok_or(WordfenceV3ProductionError::RetainedDataTooLarge)?,
2651 )
2652 }
2653
2654 const fn total(&self) -> usize {
2655 self.total
2656 }
2657}
2658
2659#[cfg(test)]
2660mod tests {
2661 use super::*;
2662
2663 const FIXTURE: &[u8] = include_bytes!(
2664 "../../../../docs/examples/wordpress-review/wordfence-v3/production.synthetic.json"
2665 );
2666 const FIRST_ID: &str = "00000000-0000-4000-8000-000000000001";
2667 const SECOND_ID: &str = "00000000-0000-4000-8000-000000000002";
2668 const FIXTURE_SHA256: [u8; 32] = [
2669 0xd9, 0xed, 0x31, 0x40, 0xf4, 0x4a, 0xe1, 0xe0, 0xa3, 0x74, 0x6b, 0xeb, 0x99, 0x37, 0xde,
2670 0x2d, 0x82, 0x9d, 0x28, 0xc0, 0x68, 0x10, 0x11, 0x20, 0xa9, 0x10, 0x4c, 0x33, 0x49, 0x90,
2671 0x1d, 0xb7,
2672 ];
2673 const FIXTURE_SEMANTIC_SHA256: [u8; 32] = [
2676 0xc7, 0xaf, 0xf0, 0x23, 0x31, 0xe8, 0x54, 0x6f, 0x14, 0x37, 0xa8, 0xd9, 0x59, 0x40, 0xdd,
2677 0x80, 0x32, 0x17, 0x49, 0x08, 0x04, 0x5f, 0xed, 0x98, 0x7d, 0x09, 0x0a, 0x96, 0xfb, 0x53,
2678 0x5e, 0xca,
2679 ];
2680
2681 #[test]
2682 fn synthetic_production_fixture_is_streamed_into_the_reviewed_typed_contract() {
2683 let import = parse_wordfence_v3_production(FIXTURE).unwrap();
2684 assert_eq!(import.byte_length(), FIXTURE.len() as u64);
2685 assert_eq!(import.sha256(), &FIXTURE_SHA256);
2686 assert_eq!(import.record_count(), 2);
2687 assert_eq!(import.software_association_count(), 3);
2688 assert_eq!(import.affected_range_count(), 4);
2689 assert_eq!(import.notices().len(), 1);
2690 assert!(import.retained_bytes() < MAX_WORDFENCE_V3_RETAINED_BYTES);
2691 assert_ne!(import.sha256(), import.semantic_sha256());
2692 assert_eq!(import.semantic_sha256(), &FIXTURE_SEMANTIC_SHA256);
2693 assert_eq!(import.mapping_revision(), WORDFENCE_V3_MAPPING_REVISION);
2694 assert_eq!(import.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V1);
2695 assert_eq!(import.identity_counts().exact(), 3);
2696 assert_eq!(import.identity_counts().candidate(), 0);
2697 assert_eq!(import.identity_counts().ambiguous(), 0);
2698 assert_eq!(import.identity_counts().unresolved(), 0);
2699
2700 let plugin = WordPressComponentIdentity::new(
2701 WordPressComponentKind::Plugin,
2702 "termivar-fixture-component",
2703 )
2704 .unwrap();
2705 let theme = WordPressComponentIdentity::new(
2706 WordPressComponentKind::Theme,
2707 "termivar-fixture-component",
2708 )
2709 .unwrap();
2710 let plugin_matches = import.associations_for(&plugin).collect::<Vec<_>>();
2711 let theme_matches = import.associations_for(&theme).collect::<Vec<_>>();
2712 assert_eq!(plugin_matches.len(), 1);
2713 assert_eq!(theme_matches.len(), 1);
2714 assert_eq!(plugin_matches[0].upstream_id(), FIRST_ID);
2715 assert_eq!(plugin_matches[0].association().affected_ranges().len(), 2);
2716 assert_eq!(theme_matches[0].association().affected_ranges().len(), 1);
2717 assert_eq!(
2718 plugin_matches[0].association().key().source_namespace(),
2719 WORDFENCE_V3_SOURCE_NAMESPACE
2720 );
2721 assert_eq!(
2722 plugin_matches[0].association().source_component().slug(),
2723 "termivar-fixture-component"
2724 );
2725 assert_eq!(
2726 plugin_matches[0].association().identity_mapping(),
2727 WordfenceV3IdentityMapping::Exact
2728 );
2729
2730 let notice = &import.notices()[0];
2731 assert!(notice.id().starts_with("wordfence-notice-sha256:"));
2732 assert_eq!(notice.id().len(), "wordfence-notice-sha256:".len() + 64);
2733 assert!(notice.id()["wordfence-notice-sha256:".len()..]
2734 .bytes()
2735 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)));
2736 }
2737
2738 #[test]
2739 fn source_identity_is_preserved_and_casefolding_is_only_a_qualified_candidate() {
2740 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
2741 document[FIRST_ID]["software"][0]["slug"] =
2742 Value::String("Termivar-Fixture-Component".to_owned());
2743 let encoded = serde_json::to_vec(&document).unwrap();
2744 let import = parse_wordfence_v3_production(encoded.as_slice()).unwrap();
2745 assert_eq!(import.mapping_revision(), WORDFENCE_V3_MAPPING_REVISION_V2);
2746 assert_eq!(import.identity_counts().exact(), 2);
2747 assert_eq!(import.identity_counts().candidate(), 1);
2748 assert_eq!(import.identity_counts().ambiguous(), 0);
2749 assert_eq!(import.identity_counts().unresolved(), 0);
2750
2751 let component = WordPressComponentIdentity::new(
2752 WordPressComponentKind::Plugin,
2753 "termivar-fixture-component",
2754 )
2755 .unwrap();
2756 let association = import
2757 .associations_for(&component)
2758 .next()
2759 .unwrap()
2760 .association();
2761 assert_eq!(
2762 association.source_component().slug(),
2763 "Termivar-Fixture-Component"
2764 );
2765 assert_eq!(
2766 association.identity_mapping(),
2767 WordfenceV3IdentityMapping::AsciiCaseFoldCandidate
2768 );
2769 assert_eq!(association.identity_collision_raw_count(), None);
2770 }
2771
2772 #[test]
2773 fn casefold_collisions_are_ambiguous_without_corrupting_exact_or_cross_kind_identity() {
2774 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
2775 let original = document[FIRST_ID]["software"][0].clone();
2776 document[FIRST_ID]["software"][0]["slug"] =
2777 Value::String("Termivar-Fixture-Component".to_owned());
2778 let mut second = original;
2779 second["slug"] = Value::String("TERMIVAR-FIXTURE-COMPONENT".to_owned());
2780 document[FIRST_ID]["software"]
2781 .as_array_mut()
2782 .unwrap()
2783 .push(second);
2784 let encoded = serde_json::to_vec(&document).unwrap();
2785 let import = parse_wordfence_v3_production(encoded.as_slice()).unwrap();
2786 assert_eq!(import.identity_counts().exact(), 2);
2787 assert_eq!(import.identity_counts().candidate(), 0);
2788 assert_eq!(import.identity_counts().ambiguous(), 2);
2789 assert_eq!(import.identity_counts().unresolved(), 0);
2790
2791 let plugin = WordPressComponentIdentity::new(
2792 WordPressComponentKind::Plugin,
2793 "termivar-fixture-component",
2794 )
2795 .unwrap();
2796 let plugin_associations = import.associations_for(&plugin).collect::<Vec<_>>();
2797 assert_eq!(plugin_associations.len(), 2);
2798 assert!(plugin_associations.iter().all(|view| {
2799 view.association().identity_mapping()
2800 == WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous
2801 && view.association().identity_collision_raw_count() == Some(2)
2802 }));
2803
2804 let theme = WordPressComponentIdentity::new(
2805 WordPressComponentKind::Theme,
2806 "termivar-fixture-component",
2807 )
2808 .unwrap();
2809 assert_eq!(
2810 import
2811 .associations_for(&theme)
2812 .next()
2813 .unwrap()
2814 .association()
2815 .identity_mapping(),
2816 WordfenceV3IdentityMapping::Exact
2817 );
2818 }
2819
2820 #[test]
2821 fn exact_spelling_remains_exact_while_a_case_variant_is_ambiguous() {
2822 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
2823 let mut case_variant = document[FIRST_ID]["software"][0].clone();
2824 case_variant["slug"] = Value::String("Termivar-Fixture-Component".to_owned());
2825 document[FIRST_ID]["software"]
2826 .as_array_mut()
2827 .unwrap()
2828 .push(case_variant);
2829 let encoded = serde_json::to_vec(&document).unwrap();
2830 let import = parse_wordfence_v3_production(encoded.as_slice()).unwrap();
2831 assert_eq!(import.identity_counts().exact(), 3);
2832 assert_eq!(import.identity_counts().candidate(), 0);
2833 assert_eq!(import.identity_counts().ambiguous(), 1);
2834 let component = WordPressComponentIdentity::new(
2835 WordPressComponentKind::Plugin,
2836 "termivar-fixture-component",
2837 )
2838 .unwrap();
2839 let associations = import.associations_for(&component).collect::<Vec<_>>();
2840 assert_eq!(associations.len(), 2);
2841 assert_eq!(
2842 associations
2843 .iter()
2844 .filter(|view| view.association().identity_mapping()
2845 == WordfenceV3IdentityMapping::Exact)
2846 .count(),
2847 1
2848 );
2849 let ambiguous = associations
2850 .iter()
2851 .find(|view| {
2852 view.association().identity_mapping()
2853 == WordfenceV3IdentityMapping::AsciiCaseFoldAmbiguous
2854 })
2855 .unwrap();
2856 assert_eq!(
2857 ambiguous.association().identity_collision_raw_count(),
2858 Some(2)
2859 );
2860 }
2861
2862 #[test]
2863 fn duplicate_source_association_inside_one_record_fails_closed() {
2864 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
2865 let duplicate = document[FIRST_ID]["software"][0].clone();
2866 document[FIRST_ID]["software"]
2867 .as_array_mut()
2868 .unwrap()
2869 .push(duplicate);
2870 let encoded = serde_json::to_vec(&document).unwrap();
2871 assert_eq!(
2872 parse_wordfence_v3_production(encoded.as_slice()),
2873 Err(WordfenceV3ProductionError::ConflictingIdentity)
2874 );
2875 }
2876
2877 #[test]
2878 fn distinct_source_association_variants_are_preserved_and_order_independent() {
2879 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
2880 let mut variant = document[FIRST_ID]["software"][0].clone();
2881 variant["name"] = Value::String("Synthetic alternate source declaration".to_owned());
2882 document[FIRST_ID]["software"]
2883 .as_array_mut()
2884 .unwrap()
2885 .push(variant);
2886 let encoded = serde_json::to_vec(&document).unwrap();
2887 let original = parse_wordfence_v3_production(encoded.as_slice()).unwrap();
2888
2889 assert_eq!(original.software_association_count(), 4);
2890 assert_eq!(
2891 original.mapping_revision(),
2892 "termivar-wordfence-v3-production/v1"
2893 );
2894 assert_eq!(
2895 original.resource_policy(),
2896 "termivar.wordfence-v3-bounded-capacity/v2"
2897 );
2898 let record = original
2899 .records()
2900 .iter()
2901 .find(|record| record.upstream_id() == FIRST_ID)
2902 .unwrap();
2903 let variants = record
2904 .software()
2905 .iter()
2906 .filter(|association| {
2907 association.source_component().kind() == WordPressComponentKind::Plugin
2908 && association.source_component().slug() == "termivar-fixture-component"
2909 })
2910 .collect::<Vec<_>>();
2911 assert_eq!(variants.len(), 2);
2912 assert_ne!(
2913 variants[0].key().source_association_sha256(),
2914 variants[1].key().source_association_sha256()
2915 );
2916
2917 document[FIRST_ID]["software"]
2918 .as_array_mut()
2919 .unwrap()
2920 .reverse();
2921 let reordered_bytes = serde_json::to_vec_pretty(&document).unwrap();
2922 let reordered = parse_wordfence_v3_production(reordered_bytes.as_slice()).unwrap();
2923 assert_ne!(original.sha256(), reordered.sha256());
2924 assert_eq!(original.semantic_sha256(), reordered.semantic_sha256());
2925 assert_eq!(original.records(), reordered.records());
2926 }
2927
2928 #[test]
2929 fn safe_opaque_but_unmappable_source_identity_is_retained_and_not_indexed() {
2930 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
2931 document[FIRST_ID]["software"][0]["slug"] =
2932 Value::String("https://Example.invalid/plugin_name".to_owned());
2933 let encoded = serde_json::to_vec(&document).unwrap();
2934 let import = parse_wordfence_v3_production(encoded.as_slice()).unwrap();
2935 assert_eq!(import.software_association_count(), 3);
2936 assert_eq!(import.identity_counts().exact(), 2);
2937 assert_eq!(import.identity_counts().candidate(), 0);
2938 assert_eq!(import.identity_counts().ambiguous(), 0);
2939 assert_eq!(import.identity_counts().unresolved(), 1);
2940 let record = import
2941 .records()
2942 .iter()
2943 .find(|record| record.upstream_id() == FIRST_ID)
2944 .unwrap();
2945 assert_eq!(record.software().len(), 1);
2946 assert_eq!(record.unresolved_software().len(), 1);
2947 assert_eq!(
2948 record.unresolved_software()[0].source_component().slug(),
2949 "https://Example.invalid/plugin_name"
2950 );
2951 let canonical = WordPressComponentIdentity::new(
2952 WordPressComponentKind::Plugin,
2953 "termivar-fixture-component",
2954 )
2955 .unwrap();
2956 assert_eq!(import.associations_for(&canonical).count(), 0);
2957 }
2958
2959 #[test]
2960 fn source_identity_controls_non_ascii_and_oversize_values_fail_closed() {
2961 for source_slug in [
2962 "bad\nslug".to_owned(),
2963 "mötley-plugin".to_owned(),
2964 "...".to_owned(),
2965 "x".repeat(MAX_WORDFENCE_V3_SOURCE_SLUG_BYTES + 1),
2966 ] {
2967 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
2968 document[FIRST_ID]["software"][0]["slug"] = Value::String(source_slug);
2969 let encoded = serde_json::to_vec(&document).unwrap();
2970 assert_eq!(
2971 parse_wordfence_v3_production(encoded.as_slice()),
2972 Err(WordfenceV3ProductionError::UnsupportedValue)
2973 );
2974 }
2975 }
2976
2977 #[test]
2978 fn semantic_digest_ignores_root_record_order_and_json_layout_but_exact_digest_does_not() {
2979 let original = parse_wordfence_v3_production(FIXTURE).unwrap();
2980 let value: Value = serde_json::from_slice(FIXTURE).unwrap();
2981 let object = value.as_object().unwrap();
2982 let reordered = format!(
2983 "{{\n {second:?}: {},\n {first:?}: {}\n}}",
2984 serde_json::to_string(&object[SECOND_ID]).unwrap(),
2985 serde_json::to_string(&object[FIRST_ID]).unwrap(),
2986 second = SECOND_ID,
2987 first = FIRST_ID,
2988 );
2989 let reordered = parse_wordfence_v3_production(reordered.as_bytes()).unwrap();
2990 assert_ne!(original.sha256(), reordered.sha256());
2991 assert_eq!(original.semantic_sha256(), reordered.semantic_sha256());
2992 assert_eq!(original.records(), reordered.records());
2993 }
2994
2995 #[test]
2996 fn semantic_digest_does_not_change_when_layout_alone_selects_the_larger_resource_envelope() {
2997 let value: Value = serde_json::from_slice(FIXTURE).unwrap();
2998 let record = serde_json::to_string(&value[FIRST_ID]).unwrap();
2999 let compact_document = format!("{{{FIRST_ID:?}:{record}}}");
3000 let compact = parse_wordfence_v3_production(compact_document.as_bytes()).unwrap();
3001 assert_eq!(compact.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V1);
3002
3003 let padding_bytes = (MAX_WORDFENCE_V3_RECORD_BYTES_V1 + 1)
3004 .checked_sub(record.len())
3005 .expect("fixture record remains below the historical envelope");
3006 let padded_record = record.replacen('{', &format!("{{{}", " ".repeat(padding_bytes)), 1);
3007 assert_eq!(padded_record.len(), MAX_WORDFENCE_V3_RECORD_BYTES_V1 + 1);
3008 let padded_document = format!("{{{FIRST_ID:?}:{padded_record}}}");
3009 let padded = parse_wordfence_v3_production(padded_document.as_bytes()).unwrap();
3010
3011 assert_eq!(padded.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V2);
3012 assert_ne!(compact.sha256(), padded.sha256());
3013 assert_eq!(compact.records(), padded.records());
3014 assert_eq!(compact.semantic_sha256(), padded.semantic_sha256());
3015 }
3016
3017 #[test]
3018 fn resource_policy_records_the_largest_import_phase_without_rewriting_old_envelopes() {
3019 assert_eq!(
3020 resource_policy_for_import(false, MAX_WORDFENCE_V3_RETAINED_BYTES_V1, 1),
3021 WORDFENCE_V3_RESOURCE_POLICY_V1
3022 );
3023 assert_eq!(
3024 resource_policy_for_import(false, MAX_WORDFENCE_V3_RETAINED_BYTES_V1 + 1, 1,),
3025 WORDFENCE_V3_RESOURCE_POLICY_V2
3026 );
3027 assert_eq!(
3028 resource_policy_for_import(true, 1, 1),
3029 WORDFENCE_V3_RESOURCE_POLICY_V2
3030 );
3031 assert_eq!(
3032 resource_policy_for_import(
3033 false,
3034 MAX_WORDFENCE_V3_RETAINED_BYTES_V2,
3035 MAX_WORDFENCE_V3_RETAINED_BYTES_V1,
3036 ),
3037 WORDFENCE_V3_RESOURCE_POLICY_V2
3038 );
3039 assert_eq!(
3040 resource_policy_for_import(false, MAX_WORDFENCE_V3_RETAINED_BYTES_V2 + 1, 1,),
3041 WORDFENCE_V3_RESOURCE_POLICY_V3
3042 );
3043 assert_eq!(
3044 resource_policy_for_import(false, 1, MAX_WORDFENCE_V3_RETAINED_BYTES_V2 + 1,),
3045 WORDFENCE_V3_RESOURCE_POLICY_V3
3046 );
3047 }
3048
3049 #[test]
3050 fn vector_growth_is_rejected_before_crossing_the_retained_budget() {
3051 let mut refused = Vec::<u64>::new();
3052 let mut no_capacity_budget = RetainedBudget::with_base(0, 0).unwrap();
3053 assert_eq!(
3054 reserve_one_with_retained_accounting(&mut refused, &mut no_capacity_budget),
3055 Err(WordfenceV3ProductionError::RetainedDataTooLarge)
3056 );
3057 assert_eq!(refused.capacity(), 0);
3058
3059 let initial_bytes = 4 * size_of::<u64>();
3060 let mut bounded = Vec::<u64>::new();
3061 let mut fixed_budget = RetainedBudget::with_base(initial_bytes, 0).unwrap();
3062 reserve_one_with_retained_accounting(&mut bounded, &mut fixed_budget).unwrap();
3063 let initial_capacity = bounded.capacity();
3064 assert_eq!(initial_capacity, 4);
3065 bounded.resize(initial_capacity, 0);
3066 assert_eq!(
3067 reserve_one_with_retained_accounting(&mut bounded, &mut fixed_budget),
3068 Err(WordfenceV3ProductionError::RetainedDataTooLarge)
3069 );
3070 assert_eq!(bounded.capacity(), initial_capacity);
3071 }
3072
3073 #[test]
3074 fn duplicate_decoded_root_nested_range_and_notice_keys_fail_closed() {
3075 let value: Value = serde_json::from_slice(FIXTURE).unwrap();
3076 let record = serde_json::to_string(&value[FIRST_ID]).unwrap();
3077 let escaped_id = format!("\\u0030{}", &FIRST_ID[1..]);
3078 let duplicate_root = format!("{{{FIRST_ID:?}:{record},\"{escaped_id}\":{record}}}");
3079 assert_eq!(
3080 parse_wordfence_v3_production(duplicate_root.as_bytes()),
3081 Err(WordfenceV3ProductionError::DuplicateKey)
3082 );
3083
3084 let fixture = String::from_utf8(FIXTURE.to_vec()).unwrap();
3085 let duplicate_range = fixture.replacen(
3086 r#""from_version": "1.0.0""#,
3087 r#""from_version": "1.0.0", "from_version": "1.0.0""#,
3088 1,
3089 );
3090 assert_eq!(
3091 parse_wordfence_v3_production(duplicate_range.as_bytes()),
3092 Err(WordfenceV3ProductionError::DuplicateKey)
3093 );
3094 let duplicate_notice = fixture.replacen(
3095 r#""message": "This is an original synthetic fixture notice, not a provider notice.""#,
3096 r#""message": "This is an original synthetic fixture notice, not a provider notice.", "message": "duplicate""#,
3097 1,
3098 );
3099 assert_eq!(
3100 parse_wordfence_v3_production(duplicate_notice.as_bytes()),
3101 Err(WordfenceV3ProductionError::DuplicateKey)
3102 );
3103 }
3104
3105 #[test]
3106 fn key_mismatch_unknown_fields_and_trailing_json_are_rejected() {
3107 let fixture = String::from_utf8(FIXTURE.to_vec()).unwrap();
3108 let mismatch = fixture.replacen(FIRST_ID, "10000000-0000-4000-8000-000000000001", 1);
3109 assert_eq!(
3110 parse_wordfence_v3_production(mismatch.as_bytes()),
3111 Err(WordfenceV3ProductionError::ConflictingIdentity)
3112 );
3113 let unknown = fixture.replacen(
3114 r#""informational": false,"#,
3115 r#""informational": false, "executable_predicate": "never","#,
3116 1,
3117 );
3118 assert_eq!(
3119 parse_wordfence_v3_production(unknown.as_bytes()),
3120 Err(WordfenceV3ProductionError::UnsupportedValue)
3121 );
3122 let trailing = format!("{fixture} true");
3123 assert_eq!(
3124 parse_wordfence_v3_production(trailing.as_bytes()),
3125 Err(WordfenceV3ProductionError::MalformedJson)
3126 );
3127 }
3128
3129 #[test]
3130 fn unknown_nested_software_range_cwe_cvss_and_notice_fields_fail_closed() {
3131 let fixture = String::from_utf8(FIXTURE.to_vec()).unwrap();
3132 for (location, needle, replacement) in [
3133 (
3134 "software",
3135 r#""slug": "termivar-fixture-component","#,
3136 r#""slug": "termivar-fixture-component", "future_software_field": true,"#,
3137 ),
3138 (
3139 "affected range",
3140 r#""from_version": "1.0.0","#,
3141 r#""from_version": "1.0.0", "future_range_field": true,"#,
3142 ),
3143 (
3144 "CWE",
3145 r#""id": 9999,"#,
3146 r#""id": 9999, "future_cwe_field": true,"#,
3147 ),
3148 (
3149 "CVSS",
3150 r#""vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N","#,
3151 r#""vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N", "future_cvss_field": true,"#,
3152 ),
3153 (
3154 "notice party",
3155 r#""notice": "Synthetic fixture material created for Termivar parser testing.","#,
3156 r#""notice": "Synthetic fixture material created for Termivar parser testing.", "future_notice_field": true,"#,
3157 ),
3158 ] {
3159 let document = fixture.replacen(needle, replacement, 1);
3160 assert_ne!(document, fixture, "test mutation missed {location}");
3161 assert_eq!(
3162 parse_wordfence_v3_production(document.as_bytes()),
3163 Err(WordfenceV3ProductionError::UnsupportedValue),
3164 "unknown {location} field must not be silently discarded"
3165 );
3166 }
3167 }
3168
3169 #[test]
3170 fn truncated_root_key_record_and_final_object_are_rejected_as_malformed() {
3171 let first_key = FIXTURE
3172 .windows(FIRST_ID.len())
3173 .position(|window| window == FIRST_ID.as_bytes())
3174 .unwrap();
3175 let first_software = FIXTURE
3176 .windows(b"\"software\"".len())
3177 .position(|window| window == b"\"software\"")
3178 .unwrap();
3179 let final_close = FIXTURE.iter().rposition(|byte| *byte == b'}').unwrap();
3180 for (location, end) in [
3181 ("root opener", 1),
3182 ("root key", first_key + 7),
3183 ("record", first_software + 6),
3184 ("middle", FIXTURE.len() / 2),
3185 ("final object", final_close),
3186 ] {
3187 assert_eq!(
3188 parse_wordfence_v3_production(&FIXTURE[..end]),
3189 Err(WordfenceV3ProductionError::MalformedJson),
3190 "truncation at {location} must not be accepted as a prefix"
3191 );
3192 }
3193 }
3194
3195 #[test]
3196 fn wildcard_is_exact_and_source_versions_are_not_interpreted_by_the_parser() {
3197 let import = parse_wordfence_v3_production(FIXTURE).unwrap();
3198 let theme = WordPressComponentIdentity::new(
3199 WordPressComponentKind::Theme,
3200 "termivar-fixture-component",
3201 )
3202 .unwrap();
3203 assert!(matches!(
3204 import
3205 .associations_for(&theme)
3206 .next()
3207 .unwrap()
3208 .association()
3209 .affected_ranges()[0]
3210 .from()
3211 .value(),
3212 WordfenceV3RangeValue::Any
3213 ));
3214
3215 let literal = String::from_utf8(FIXTURE.to_vec()).unwrap().replacen(
3216 r#""from_version": "*""#,
3217 r#""from_version": "1.*""#,
3218 1,
3219 );
3220 let import = parse_wordfence_v3_production(literal.as_bytes()).unwrap();
3221 assert!(matches!(
3222 import
3223 .associations_for(&theme)
3224 .next()
3225 .unwrap()
3226 .association()
3227 .affected_ranges()[0]
3228 .from()
3229 .value(),
3230 WordfenceV3RangeValue::Declared(value) if value == "1.*"
3231 ));
3232
3233 let opaque_version = "44.0 (17-08-2023)";
3234 let opaque_unicode_version = "0.1.2 β";
3235 let plugin = WordPressComponentIdentity::new(
3236 WordPressComponentKind::Plugin,
3237 "termivar-fixture-component",
3238 )
3239 .unwrap();
3240 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
3241 document[FIRST_ID]["software"][0]["affected_versions"]["[1.0.0, 1.2.3]"]["to_version"] =
3242 Value::String(opaque_unicode_version.to_owned());
3243 document[FIRST_ID]["software"][0]["patched_versions"] =
3244 Value::Array(vec![Value::String(opaque_version.to_owned())]);
3245 let encoded = serde_json::to_vec(&document).unwrap();
3246 let import = parse_wordfence_v3_production(encoded.as_slice()).unwrap();
3247 assert_eq!(import.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V2);
3248 let association = import
3249 .associations_for(&plugin)
3250 .next()
3251 .unwrap()
3252 .association();
3253 let changed_range = association
3254 .affected_ranges()
3255 .iter()
3256 .find(|range| range.label() == "[1.0.0, 1.2.3]")
3257 .expect("changed source range remains present");
3258 assert!(matches!(
3259 changed_range.to().value(),
3260 WordfenceV3RangeValue::Declared(value) if value == opaque_unicode_version
3261 ));
3262 assert_eq!(association.patched_versions(), [opaque_version]);
3263
3264 for invalid in [" leading", "trailing ", "embedded\tcontrol"] {
3265 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
3266 document[FIRST_ID]["software"][0]["patched_versions"] =
3267 Value::Array(vec![Value::String(invalid.to_owned())]);
3268 let encoded = serde_json::to_vec(&document).unwrap();
3269 assert_eq!(
3270 parse_wordfence_v3_production(encoded.as_slice()),
3271 Err(WordfenceV3ProductionError::UnsupportedValue)
3272 );
3273 }
3274 }
3275
3276 #[test]
3277 fn repeated_researcher_attribution_is_preserved_without_becoming_an_identity_conflict() {
3278 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
3279 document[FIRST_ID]["researchers"] = serde_json::json!(["Researcher", "Researcher"]);
3280 let encoded = serde_json::to_vec(&document).unwrap();
3281 let import = parse_wordfence_v3_production(encoded.as_slice()).unwrap();
3282 let record = import
3283 .records()
3284 .iter()
3285 .find(|record| record.upstream_id() == FIRST_ID)
3286 .unwrap();
3287 assert_eq!(record.researchers(), ["Researcher", "Researcher"]);
3288 assert_eq!(import.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V2);
3289 }
3290
3291 #[test]
3292 fn invalid_dates_cvss_bounds_and_executable_or_credentialed_urls_are_rejected() {
3293 let fixture = String::from_utf8(FIXTURE.to_vec()).unwrap();
3294 for invalid in [
3295 fixture.replacen("2026-01-02 03:04:05", "2026-13-02 03:04:05", 1),
3296 fixture.replacen("2026-01-02 03:04:05", "2026-02-30 03:04:05", 1),
3297 fixture.replacen(r#""score": 5.3"#, r#""score": 5.33"#, 1),
3298 fixture.replacen(r#""score": 5.3"#, r#""score": 10.1"#, 1),
3299 fixture.replacen(
3300 "https://example.invalid/termivar/wordfence-v3/fixture-advisory-1",
3301 "javascript:alert(1)",
3302 1,
3303 ),
3304 fixture.replacen(
3305 "https://example.invalid/termivar/wordfence-v3/synthetic-fixture-terms",
3306 "https://user:secret@example.invalid/terms",
3307 1,
3308 ),
3309 ] {
3310 assert_eq!(
3311 parse_wordfence_v3_production(invalid.as_bytes()),
3312 Err(WordfenceV3ProductionError::UnsupportedValue)
3313 );
3314 }
3315 }
3316
3317 #[test]
3318 fn defiant_notice_requires_an_exact_official_record_reference_at_preflight() {
3319 for reference in [
3320 "http://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture",
3321 "https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture",
3322 "https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source=api-test",
3323 ] {
3324 let document = document_with_defiant_reference(Some(reference));
3325 assert!(parse_wordfence_v3_production(document.as_slice()).is_ok());
3326 }
3327
3328 for reference in [
3329 None,
3330 Some("https://wordfence.com/threat-intel/vulnerabilities/synthetic-fixture"),
3331 Some("https://www.wordfence.com/help/synthetic-fixture"),
3332 Some("https://www.wordfence.com/threat-intel/vulnerabilities/"),
3333 Some("https://www.wordfence.com:8443/threat-intel/vulnerabilities/synthetic-fixture"),
3334 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?q=1"),
3335 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source"),
3336 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source="),
3337 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source=api-test&source=other"),
3338 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source=api-test&extra=1"),
3339 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?other=api-test"),
3340 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source=api%2Dtest"),
3341 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source=api+test"),
3342 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source=api-test#part"),
3343 Some("https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture#part"),
3344 ] {
3345 let document = document_with_defiant_reference(reference);
3346 assert_eq!(
3347 parse_wordfence_v3_production(document.as_slice()),
3348 Err(WordfenceV3ProductionError::UnsupportedValue)
3349 );
3350 }
3351
3352 let oversized_source = format!(
3353 "https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source={}",
3354 "a".repeat(65)
3355 );
3356 let document = document_with_defiant_reference(Some(&oversized_source));
3357 assert_eq!(
3358 parse_wordfence_v3_production(document.as_slice()),
3359 Err(WordfenceV3ProductionError::UnsupportedValue)
3360 );
3361
3362 let maximum_source = format!(
3363 "https://www.wordfence.com/threat-intel/vulnerabilities/synthetic-fixture?source={}",
3364 "a".repeat(64)
3365 );
3366 let document = document_with_defiant_reference(Some(&maximum_source));
3367 assert!(parse_wordfence_v3_production(document.as_slice()).is_ok());
3368 }
3369
3370 #[test]
3371 fn equivalent_json_number_spellings_use_one_reader_compatible_cvss_projection() {
3372 let fixture = String::from_utf8(FIXTURE.to_vec()).unwrap();
3373 for (spelling, expected) in [("5.30", "5.3"), ("1e0", "1.0")] {
3374 let document =
3375 fixture.replacen(r#""score": 5.3"#, &format!(r#""score": {spelling}"#), 1);
3376 let import = parse_wordfence_v3_production(document.as_bytes()).unwrap();
3377 assert_eq!(import.records()[0].cvss().unwrap().score(), expected);
3378 }
3379 }
3380
3381 #[test]
3382 fn nullable_cve_link_relation_lowercase_ids_and_empty_source_ranges_are_explicit() {
3383 let fixture = String::from_utf8(FIXTURE.to_vec()).unwrap();
3384 let cve_without_link = fixture.replacen(r#""cve": null"#, r#""cve": "CVE-2026-12345""#, 1);
3385 assert!(parse_wordfence_v3_production(cve_without_link.as_bytes()).is_ok());
3386
3387 let link_without_cve = fixture.replacen(
3388 r#""cve_link": null"#,
3389 r#""cve_link": "https://www.cve.org/CVERecord?id=CVE-2026-12345""#,
3390 1,
3391 );
3392 assert_eq!(
3393 parse_wordfence_v3_production(link_without_cve.as_bytes()),
3394 Err(WordfenceV3ProductionError::ConflictingIdentity)
3395 );
3396
3397 let uppercase_id = fixture.replacen(FIRST_ID, "ABCDEFAB-CDEF-4ABC-8DEF-ABCDEFABCDEF", 2);
3398 assert_eq!(
3399 parse_wordfence_v3_production(uppercase_id.as_bytes()),
3400 Err(WordfenceV3ProductionError::UnsupportedValue)
3401 );
3402
3403 let mut no_ranges: Value = serde_json::from_slice(FIXTURE).unwrap();
3404 no_ranges[FIRST_ID]["software"][0]["affected_versions"] =
3405 Value::Object(serde_json::Map::new());
3406 let no_ranges = serde_json::to_vec(&no_ranges).unwrap();
3407 let import = parse_wordfence_v3_production(no_ranges.as_slice()).unwrap();
3408 let plugin = WordPressComponentIdentity::new(
3409 WordPressComponentKind::Plugin,
3410 "termivar-fixture-component",
3411 )
3412 .unwrap();
3413 assert!(import
3414 .associations_for(&plugin)
3415 .next()
3416 .unwrap()
3417 .association()
3418 .affected_ranges()
3419 .is_empty());
3420 }
3421
3422 #[test]
3423 fn external_record_limits_admit_the_documented_range_and_label_boundaries() {
3424 let maximum_ranges = document_with_range_labels(
3425 (0..MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION).map(|index| format!("range-{index:02}")),
3426 );
3427 let import = parse_wordfence_v3_production(maximum_ranges.as_slice()).unwrap();
3428 assert_eq!(
3429 import.records()[0].software()[0].affected_ranges().len(),
3430 MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION
3431 );
3432 assert_eq!(import.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V2);
3433
3434 let excessive_ranges = document_with_range_labels(
3435 (0..=MAX_WORDFENCE_V3_RANGES_PER_ASSOCIATION).map(|index| format!("range-{index:02}")),
3436 );
3437 assert_eq!(
3438 parse_wordfence_v3_production(excessive_ranges.as_slice()),
3439 Err(WordfenceV3ProductionError::StructuralLimitExceeded)
3440 );
3441
3442 let seventy_eight = document_with_range_and_patch_counts(78, 78);
3443 let import = parse_wordfence_v3_production(seventy_eight.as_slice()).unwrap();
3444 let association = &import.records()[0].software()[0];
3445 assert_eq!(association.affected_ranges().len(), 78);
3446 assert_eq!(association.patched_versions().len(), 78);
3447
3448 let maximum_patches =
3449 document_with_range_and_patch_counts(1, MAX_WORDFENCE_V3_PATCHED_VERSIONS);
3450 assert!(parse_wordfence_v3_production(maximum_patches.as_slice()).is_ok());
3451 let excessive_patches =
3452 document_with_range_and_patch_counts(1, MAX_WORDFENCE_V3_PATCHED_VERSIONS + 1);
3453 assert_eq!(
3454 parse_wordfence_v3_production(excessive_patches.as_slice()),
3455 Err(WordfenceV3ProductionError::StructuralLimitExceeded)
3456 );
3457
3458 let maximum_label =
3459 document_with_range_labels(["x".repeat(MAX_WORDFENCE_V3_RANGE_LABEL_BYTES)]);
3460 assert!(parse_wordfence_v3_production(maximum_label.as_slice()).is_ok());
3461
3462 let oversized_label =
3463 document_with_range_labels(["x".repeat(MAX_WORDFENCE_V3_RANGE_LABEL_BYTES + 1)]);
3464 assert_eq!(
3465 parse_wordfence_v3_production(oversized_label.as_slice()),
3466 Err(WordfenceV3ProductionError::StructuralLimitExceeded)
3467 );
3468 }
3469
3470 #[test]
3471 fn expanded_description_and_record_limits_have_finite_new_boundaries() {
3472 let fixture = String::from_utf8(FIXTURE.to_vec()).unwrap();
3473 let original_description =
3474 "Fictional prose created only to exercise bounded Production-format ingestion. It is not a vulnerability claim.";
3475 let formerly_excessive = fixture.replacen(
3476 original_description,
3477 &"d".repeat(MAX_WORDFENCE_V3_DESCRIPTION_BYTES_V1 + 1),
3478 1,
3479 );
3480 let import = parse_wordfence_v3_production(formerly_excessive.as_bytes()).unwrap();
3481 assert_eq!(import.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V2);
3482
3483 let maximum_description = fixture.replacen(
3484 original_description,
3485 &"d".repeat(MAX_WORDFENCE_V3_DESCRIPTION_BYTES),
3486 1,
3487 );
3488 assert!(parse_wordfence_v3_production(maximum_description.as_bytes()).is_ok());
3489 let excessive_description = fixture.replacen(
3490 original_description,
3491 &"d".repeat(MAX_WORDFENCE_V3_DESCRIPTION_BYTES + 1),
3492 1,
3493 );
3494 assert_eq!(
3495 parse_wordfence_v3_production(excessive_description.as_bytes()),
3496 Err(WordfenceV3ProductionError::StructuralLimitExceeded)
3497 );
3498
3499 let expanded_record = document_between_record_limits();
3500 assert!(expanded_record.len() > MAX_WORDFENCE_V3_RECORD_BYTES_V1);
3501 assert!(expanded_record.len() <= MAX_WORDFENCE_V3_RECORD_BYTES);
3502 let import = parse_wordfence_v3_production(expanded_record.as_slice()).unwrap();
3503 assert_eq!(import.resource_policy(), WORDFENCE_V3_RESOURCE_POLICY_V2);
3504 }
3505
3506 #[test]
3507 fn retained_budget_stops_streaming_before_unread_records() {
3508 let observed = std::rc::Rc::new(std::cell::Cell::new(0));
3509 let reader = OneByteCountingReader {
3510 bytes: FIXTURE,
3511 position: 0,
3512 observed: std::rc::Rc::clone(&observed),
3513 };
3514 let retained_limit = size_of::<WordfenceV3ProductionImport>() + 512;
3518 assert_eq!(
3519 parse_wordfence_v3_production_with_limits(reader, FIXTURE.len(), retained_limit),
3520 Err(WordfenceV3ProductionError::RetainedDataTooLarge)
3521 );
3522 let second_record_offset = FIXTURE
3523 .windows(SECOND_ID.len())
3524 .position(|window| window == SECOND_ID.as_bytes())
3525 .unwrap();
3526 assert!(
3527 observed.get() < second_record_offset,
3528 "the retained-data guard must stop before reading the second record"
3529 );
3530 }
3531
3532 #[test]
3533 fn vector_growth_is_amortized_and_charges_actual_capacity_deltas() {
3534 fn assert_growth_contract<T: Clone>(value: T) {
3535 let mut values = Vec::new();
3536 let mut retained = RetainedBudget::with_base(usize::MAX, 0).unwrap();
3537 let mut growth_events = 0_usize;
3538 for _ in 0..128 {
3539 let previous_capacity = values.capacity();
3540 let previous_retained = retained.total();
3541 reserve_one_with_retained_accounting(&mut values, &mut retained).unwrap();
3542 let capacity_delta = values.capacity() - previous_capacity;
3543 if capacity_delta != 0 {
3544 growth_events += 1;
3545 }
3546 assert_eq!(
3547 retained.total() - previous_retained,
3548 capacity_delta * size_of::<T>()
3549 );
3550 values.push(value.clone());
3551 assert_eq!(retained.total(), values.capacity() * size_of::<T>());
3552 }
3553 assert!(
3554 growth_events < values.len(),
3555 "reserve-one growth must not reallocate for every pushed item"
3556 );
3557 }
3558
3559 assert_growth_contract((0_usize, 0_usize));
3560 let import = parse_wordfence_v3_production(FIXTURE).unwrap();
3561 assert_growth_contract(import.records()[0].clone());
3562 }
3563
3564 fn document_with_range_labels(labels: impl IntoIterator<Item = String>) -> Vec<u8> {
3565 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
3566 let template =
3567 document[FIRST_ID]["software"][0]["affected_versions"]["[1.0.0, 1.2.3]"].clone();
3568 let ranges = labels
3569 .into_iter()
3570 .map(|label| (label, template.clone()))
3571 .collect::<serde_json::Map<_, _>>();
3572 document[FIRST_ID]["software"][0]["affected_versions"] = Value::Object(ranges);
3573 serde_json::to_vec(&document).unwrap()
3574 }
3575
3576 fn document_with_range_and_patch_counts(range_count: usize, patch_count: usize) -> Vec<u8> {
3577 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
3578 let template =
3579 document[FIRST_ID]["software"][0]["affected_versions"]["[1.0.0, 1.2.3]"].clone();
3580 document[FIRST_ID]["software"][0]["affected_versions"] = Value::Object(
3581 (0..range_count)
3582 .map(|index| (format!("range-{index:03}"), template.clone()))
3583 .collect(),
3584 );
3585 document[FIRST_ID]["software"][0]["patched_versions"] = Value::Array(
3586 (0..patch_count)
3587 .map(|index| Value::String(format!("1.0.{index}")))
3588 .collect(),
3589 );
3590 serde_json::to_vec(&document).unwrap()
3591 }
3592
3593 fn document_between_record_limits() -> Vec<u8> {
3594 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
3595 document.as_object_mut().unwrap().remove(SECOND_ID);
3596 let mut template = document[FIRST_ID]["software"][0].clone();
3597 template["name"] = Value::String("n".repeat(128));
3598 template["remediation"] = Value::String("r".repeat(600));
3599 let software = (0..600)
3600 .map(|index| {
3601 let mut association = template.clone();
3602 association["slug"] = Value::String(format!("termivar-capacity-{index:04}"));
3603 association
3604 })
3605 .collect();
3606 document[FIRST_ID]["software"] = Value::Array(software);
3607 serde_json::to_vec(&document).unwrap()
3608 }
3609
3610 fn document_with_defiant_reference(reference: Option<&str>) -> Vec<u8> {
3611 let mut document: Value = serde_json::from_slice(FIXTURE).unwrap();
3612 let copyrights = document[FIRST_ID]["copyrights"].as_object_mut().unwrap();
3613 let notice = copyrights.remove("termivar_fixture_author").unwrap();
3614 copyrights.insert("defiant".to_owned(), notice);
3615 document[FIRST_ID]["references"] = Value::Array(
3616 reference
3617 .into_iter()
3618 .map(|value| Value::String(value.to_owned()))
3619 .collect(),
3620 );
3621 serde_json::to_vec(&document).unwrap()
3622 }
3623
3624 struct OneByteCountingReader<'a> {
3625 bytes: &'a [u8],
3626 position: usize,
3627 observed: std::rc::Rc<std::cell::Cell<usize>>,
3628 }
3629
3630 #[derive(Default)]
3631 struct DeterministicReadTrace {
3632 data_reads: usize,
3633 interrupted_reads: usize,
3634 eof_reads: usize,
3635 bytes_returned: usize,
3636 }
3637
3638 struct DeterministicChunkReader<'a> {
3639 bytes: &'a [u8],
3640 position: usize,
3641 maximum_chunk: usize,
3642 interrupt_before_each_result: bool,
3643 interruption_pending: bool,
3644 trace: std::rc::Rc<std::cell::RefCell<DeterministicReadTrace>>,
3645 }
3646
3647 impl Read for DeterministicChunkReader<'_> {
3648 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
3649 if buffer.is_empty() {
3650 return Ok(0);
3651 }
3652 if self.interrupt_before_each_result && self.interruption_pending {
3653 self.interruption_pending = false;
3654 self.trace.borrow_mut().interrupted_reads += 1;
3655 return Err(std::io::Error::from(ErrorKind::Interrupted));
3656 }
3657 if self.position == self.bytes.len() {
3658 self.trace.borrow_mut().eof_reads += 1;
3659 return Ok(0);
3660 }
3661 let copied = buffer
3662 .len()
3663 .min(self.maximum_chunk)
3664 .min(self.bytes.len() - self.position);
3665 buffer[..copied].copy_from_slice(&self.bytes[self.position..self.position + copied]);
3666 self.position += copied;
3667 self.interruption_pending = self.interrupt_before_each_result;
3668 let mut trace = self.trace.borrow_mut();
3669 trace.data_reads += 1;
3670 trace.bytes_returned += copied;
3671 Ok(copied)
3672 }
3673 }
3674
3675 #[test]
3676 fn one_byte_chunked_and_interrupted_readers_complete_once_through_eof() {
3677 for (label, maximum_chunk, interrupted) in [
3678 ("one-byte", 1_usize, false),
3679 ("chunked", 37, false),
3680 ("interrupted", 29, true),
3681 ] {
3682 let trace =
3683 std::rc::Rc::new(std::cell::RefCell::new(DeterministicReadTrace::default()));
3684 let reader = DeterministicChunkReader {
3685 bytes: FIXTURE,
3686 position: 0,
3687 maximum_chunk,
3688 interrupt_before_each_result: interrupted,
3689 interruption_pending: interrupted,
3690 trace: std::rc::Rc::clone(&trace),
3691 };
3692 let import = parse_wordfence_v3_production(reader).unwrap();
3693 assert_eq!(import.byte_length(), FIXTURE.len() as u64, "{label}");
3694 assert_eq!(import.sha256(), &FIXTURE_SHA256, "{label}");
3695 assert_eq!(import.record_count(), 2, "{label}");
3696 assert_eq!(import.software_association_count(), 3, "{label}");
3697 assert_eq!(import.affected_range_count(), 4, "{label}");
3698
3699 let trace = trace.borrow();
3700 let expected_data_reads = FIXTURE.len().div_ceil(maximum_chunk);
3701 assert_eq!(trace.data_reads, expected_data_reads, "{label}");
3702 assert_eq!(trace.bytes_returned, FIXTURE.len(), "{label}");
3703 assert_eq!(trace.eof_reads, 1, "{label}");
3704 assert_eq!(
3705 trace.interrupted_reads,
3706 if interrupted {
3707 expected_data_reads + 1
3708 } else {
3709 0
3710 },
3711 "{label}"
3712 );
3713 }
3714 }
3715
3716 impl Read for OneByteCountingReader<'_> {
3717 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
3718 if buffer.is_empty() || self.position == self.bytes.len() {
3719 return Ok(0);
3720 }
3721 buffer[0] = self.bytes[self.position];
3722 self.position += 1;
3723 self.observed.set(self.position);
3724 Ok(1)
3725 }
3726 }
3727
3728 #[test]
3729 fn record_retained_and_reader_failures_are_bounded_and_typed() {
3730 let huge_record = format!(
3731 "{{{id:?}:{{\"id\":{id:?},\"title\":\"x\",\"software\":[],\"informational\":false,\"description\":\"{}\",\"references\":[],\"cwe\":null,\"cvss\":null,\"cve\":null,\"cve_link\":null,\"researchers\":[],\"published\":null,\"updated\":null,\"copyrights\":null}}}}",
3732 "x".repeat(MAX_WORDFENCE_V3_RECORD_BYTES),
3733 id = FIRST_ID,
3734 );
3735 assert_eq!(
3736 parse_wordfence_v3_production(huge_record.as_bytes()),
3737 Err(WordfenceV3ProductionError::RecordTooLarge)
3738 );
3739
3740 let mut retained = RetainedBudget::with_base(
3741 MAX_WORDFENCE_V3_RETAINED_BYTES,
3742 MAX_WORDFENCE_V3_RETAINED_BYTES,
3743 )
3744 .unwrap();
3745 assert_eq!(
3746 retained.add(1),
3747 Err(WordfenceV3ProductionError::RetainedDataTooLarge)
3748 );
3749 assert_eq!(
3750 parse_wordfence_v3_production(FailingReader { remaining: 8 }),
3751 Err(WordfenceV3ProductionError::ReadFailed)
3752 );
3753 assert_eq!(
3754 parse_wordfence_v3_production(&b""[..]),
3755 Err(WordfenceV3ProductionError::EmptyInput)
3756 );
3757 }
3758
3759 struct FailingReader {
3760 remaining: usize,
3761 }
3762
3763 impl Read for FailingReader {
3764 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
3765 if self.remaining == 0 {
3766 return Err(std::io::Error::other("synthetic read failure"));
3767 }
3768 let copied = buffer.len().min(self.remaining);
3769 buffer[..copied].fill(b' ');
3770 self.remaining -= copied;
3771 Ok(copied)
3772 }
3773 }
3774
3775 #[test]
3776 fn a_small_cursor_limit_detects_raw_growth_without_allocating_the_public_ceiling() {
3777 let observed = std::rc::Rc::new(std::cell::Cell::new(0));
3778 let reader = BulkCountingReader {
3779 remaining: 1_000,
3780 observed: std::rc::Rc::clone(&observed),
3781 };
3782 let mut cursor = MeasuredCursor::with_limit(reader, 8);
3783 for _ in 0..8 {
3784 assert!(cursor.next_byte().unwrap().is_some());
3785 }
3786 assert_eq!(
3787 cursor.next_byte(),
3788 Err(WordfenceV3ProductionError::InputTooLarge)
3789 );
3790 assert_eq!(observed.get(), 9);
3791 assert_eq!(MAX_WORDFENCE_V3_PRODUCTION_BYTES, 256 * 1024 * 1024);
3792 }
3793
3794 struct BulkCountingReader {
3795 remaining: usize,
3796 observed: std::rc::Rc<std::cell::Cell<usize>>,
3797 }
3798
3799 impl Read for BulkCountingReader {
3800 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
3801 let copied = buffer.len().min(self.remaining);
3802 buffer[..copied].fill(b'x');
3803 self.remaining -= copied;
3804 self.observed.set(self.observed.get() + copied);
3805 Ok(copied)
3806 }
3807 }
3808}