Skip to main content

termivar_scanner/wordpress_review/
wordfence_v3.rs

1//! Bounded, transport-free decoding of a caller-supplied Wordfence V3
2//! Production-format vulnerability export.
3//!
4//! Parsing establishes only the structure and exact bytes of a local export.
5//! It does not authenticate its producer, establish feed completeness, select
6//! an installed target, or choose version-comparison semantics.
7
8use 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;
44// One message plus at most fifteen attribution entries is the supported
45// Production-format notice projection.
46pub 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;
50// These are external-import decoder limits. A supported affected_versions map
51// may contain 128 range labels and each label may contain 256 UTF-8 bytes; the
52// stricter native Termivar catalogue decoder retains its existing limits.
53const 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";
59// Rust's BTree implementation does not expose node allocation sizes. Charge a
60// conservative per-logical-entry allowance in addition to stored key/value
61// sizes so the public retained-data budget never treats nodes as free.
62const 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
676/// Parses one complete, bounded Production-format export from a caller-owned
677/// reader. The root map is consumed incrementally and only one bounded raw
678/// record plus its decoded value is live during conversion.
679pub 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 != &notice {
750                        return Err(WordfenceV3ProductionError::ConflictingIdentity);
751                    }
752                } else {
753                    retained_during_parse.add_notice_map_entry(&id, &notice)?;
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    // Root-key duplicate detection is complete. Release that temporary tree
805    // before constructing the final notice vector and component index so the
806    // retained-data model does not rely on an optimizer shortening its scope.
807    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    // The notice map was consumed above, so its nodes and separately allocated
824    // keys are no longer live. Final-form accounting below starts fresh rather
825    // than carrying the conservative parse-peak counter forward.
826    let base_retained = retained_records_and_notices_bytes(
827        &records,
828        records.capacity(),
829        &notice_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        &notice_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    // The policy records the bounded import envelope the parser actually
847    // needed, not only the size of the returned index. This prevents a
848    // temporary root-key/collision phase that crossed an older ceiling from
849    // being mislabeled as having fit that historical policy.
850    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        &notice_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    // Collision discovery is a bounded parse-time allocation, not part of the
899    // returned index. Keep it flat so a distinct singleton tree allocation is
900    // not hidden behind every candidate key. The owned pair capacity and both
901    // cloned strings are charged before they are allocated.
902    let mut collision_budget = RetainedBudget::with_base(maximum_retained, retained_base)?;
903    let mut spellings = Vec::<(
904        WordPressComponentIdentity,
905        WordfenceV3SourceComponentIdentity,
906    )>::new();
907    // Exact-only keys cannot produce a case-fold collision, so avoid cloning
908    // every source identity in a large ordinary feed. Seed only keys that have
909    // a candidate spelling, then add exact spellings for those same keys.
910    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            // Permit one sentinel byte beyond the accepted input so growth is
1119            // detected without allowing BufReader to prefetch past that bound.
1120            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    // JSON numeric spellings are semantically equivalent. Normalize once,
1866    // then retain only the bounded score grammar shared by the saved-audit
1867    // writer and strict display-only reader.
1868    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    // The provider field is retained as an opaque, inert source identifier.
2002    // Printable punctuation is not interpreted as a path or URL and will fail
2003    // canonical matching below; controls, non-ASCII text and oversize values
2004    // remain structural rejections.
2005    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    // The accepted Production form may attach one inert source marker to the
2064    // record URL. Keep the allowance narrower than general URL query syntax.
2065    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        &notice.id,
2531        &notice.message,
2532        &notice.party,
2533        &notice.notice,
2534        &notice.license,
2535        &notice.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    // Charge the requested allocation before making it. This prevents an
2587    // attacker-controlled growth boundary from allocating first and only then
2588    // discovering that the retained-data ceiling was crossed.
2589    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    // Independently calculated from the documented v1 framing contract. This
2674    // is intentionally not produced by the parser or digest helper under test.
2675    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        // This permits the import header and first root identity but not the
3515        // first decoded record/notices. It exercises the production parser's
3516        // incremental retained-data guard without allocating 64 MiB in a test.
3517        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}