Skip to main content

termivar_scanner/
wordpress_version.rs

1//! Bounded version keys shared by WordPress production and offline readers.
2//!
3//! This module owns no advisory policy. A caller must select one closed
4//! comparison profile before parsing both operands. The PHP release-subset
5//! ordering is pinned to PHP 8.3.0's `versioning.c` at commit
6//! `d26068059e83fe40de3430a512471d194119bee0`; only the grammar accepted below
7//! is supported, rather than PHP's permissive handling of arbitrary labels.
8
9use std::cmp::Ordering;
10
11use thiserror::Error;
12
13/// Returns the bounded interpretation work for one external association:
14/// one visit per range, one parse per declared patched version, and the
15/// patched-version by range checks needed to detect source contradictions.
16pub(crate) fn checked_external_interpretation_work(
17    range_count: usize,
18    patched_version_count: usize,
19) -> Option<usize> {
20    range_count
21        .checked_add(patched_version_count)?
22        .checked_add(range_count.checked_mul(patched_version_count)?)
23}
24
25pub(crate) fn checked_accumulate_external_interpretation_work(
26    current: usize,
27    range_count: usize,
28    patched_version_count: usize,
29    limit: usize,
30) -> Option<usize> {
31    let next = current.checked_add(checked_external_interpretation_work(
32        range_count,
33        patched_version_count,
34    )?)?;
35    (next <= limit).then_some(next)
36}
37
38pub(crate) const MAX_WORDPRESS_VERSION_COMPONENTS: usize = 8;
39pub(crate) const MAX_WORDPRESS_VERSION_BYTES: usize = 64;
40const MAX_PHP_NUMERIC_VALUE: u32 = 2_147_483_647;
41const MAX_PHP_NUMERIC_DIGITS: usize = 10;
42
43#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
44pub enum WordPressComparisonProfile {
45    NumericDottedV1,
46    PhpReleaseSubsetV1,
47}
48
49impl WordPressComparisonProfile {
50    #[cfg(feature = "wordpress-review")]
51    #[must_use]
52    pub const fn id(self) -> &'static str {
53        match self {
54            Self::NumericDottedV1 => "numeric-dotted/v1",
55            Self::PhpReleaseSubsetV1 => "php-release-subset/v1",
56        }
57    }
58
59    pub(crate) fn parse(value: &str) -> Result<Self, ProfiledVersionError> {
60        match value {
61            "numeric-dotted/v1" => Ok(Self::NumericDottedV1),
62            "php-release-subset/v1" => Ok(Self::PhpReleaseSubsetV1),
63            _ => Err(ProfiledVersionError),
64        }
65    }
66}
67
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct NumericDottedVersion {
70    components: Vec<u32>,
71}
72
73impl NumericDottedVersion {
74    pub fn parse(value: &str) -> Result<Self, NumericDottedVersionError> {
75        if value.is_empty() || value.len() > MAX_WORDPRESS_VERSION_BYTES {
76            return Err(NumericDottedVersionError);
77        }
78        let mut components = Vec::new();
79        for part in value.split('.') {
80            if components.len() == MAX_WORDPRESS_VERSION_COMPONENTS
81                || part.is_empty()
82                || !part.bytes().all(|byte| byte.is_ascii_digit())
83                || (part.len() > 1 && part.starts_with('0'))
84            {
85                return Err(NumericDottedVersionError);
86            }
87            let component = part
88                .bytes()
89                .try_fold(0_u32, |value, digit| {
90                    value.checked_mul(10)?.checked_add(u32::from(digit - b'0'))
91                })
92                .ok_or(NumericDottedVersionError)?;
93            components.push(component);
94        }
95        while components.len() > 1 && components.last() == Some(&0) {
96            components.pop();
97        }
98        Ok(Self { components })
99    }
100
101    #[cfg(feature = "wordpress-review")]
102    #[must_use]
103    pub fn components(&self) -> &[u32] {
104        &self.components
105    }
106}
107
108impl Ord for NumericDottedVersion {
109    fn cmp(&self, other: &Self) -> Ordering {
110        self.components.cmp(&other.components)
111    }
112}
113
114impl PartialOrd for NumericDottedVersion {
115    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
116        Some(self.cmp(other))
117    }
118}
119
120#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
121#[error("unsupported numeric-dotted WordPress version")]
122pub struct NumericDottedVersionError;
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125enum PhpReleaseStage {
126    Dev,
127    Alpha,
128    Beta,
129    Rc,
130    PatchLevel,
131}
132
133impl PhpReleaseStage {
134    const fn rank(self) -> u8 {
135        match self {
136            Self::Dev => 0,
137            Self::Alpha => 1,
138            Self::Beta => 2,
139            Self::Rc => 3,
140            Self::PatchLevel => 5,
141        }
142    }
143}
144
145/// A deliberately bounded subset of PHP's release-version comparison input.
146///
147/// Separators and recognized aliases are retained only in the caller's raw
148/// evidence. This key contains the semantic token sequence used for ordering.
149#[derive(Clone, Debug)]
150pub struct PhpReleaseSubsetVersion {
151    components: [u32; MAX_WORDPRESS_VERSION_COMPONENTS],
152    component_count: u8,
153    suffix: Option<PhpReleaseStage>,
154    suffix_counter: Option<u32>,
155}
156
157impl PhpReleaseSubsetVersion {
158    pub fn parse(value: &str) -> Result<Self, PhpReleaseSubsetVersionError> {
159        if value.is_empty()
160            || value.len() > MAX_WORDPRESS_VERSION_BYTES
161            || !value.is_ascii()
162            || value.bytes().any(|byte| byte.is_ascii_control())
163        {
164            return Err(PhpReleaseSubsetVersionError);
165        }
166
167        let bytes = value.as_bytes();
168        let mut components = [0_u32; MAX_WORDPRESS_VERSION_COMPONENTS];
169        let mut component_count = 0_usize;
170        let mut cursor = 0_usize;
171
172        loop {
173            if component_count == MAX_WORDPRESS_VERSION_COMPONENTS {
174                return Err(PhpReleaseSubsetVersionError);
175            }
176            components[component_count] = parse_php_number(bytes, &mut cursor)?;
177            component_count += 1;
178            if cursor == bytes.len() {
179                return Ok(Self {
180                    components,
181                    component_count: component_count as u8,
182                    suffix: None,
183                    suffix_counter: None,
184                });
185            }
186
187            if is_php_separator(bytes[cursor]) {
188                cursor += 1;
189                if cursor == bytes.len() {
190                    return Err(PhpReleaseSubsetVersionError);
191                }
192                if bytes[cursor].is_ascii_digit() {
193                    continue;
194                }
195            }
196            break;
197        }
198
199        let suffix = parse_php_suffix(bytes, &mut cursor)?;
200        let suffix_counter = if cursor == bytes.len() {
201            None
202        } else {
203            if is_php_separator(bytes[cursor]) {
204                cursor += 1;
205                if cursor == bytes.len() {
206                    return Err(PhpReleaseSubsetVersionError);
207                }
208            }
209            let counter = parse_php_number(bytes, &mut cursor)?;
210            if cursor != bytes.len() {
211                return Err(PhpReleaseSubsetVersionError);
212            }
213            Some(counter)
214        };
215
216        Ok(Self {
217            components,
218            component_count: component_count as u8,
219            suffix: Some(suffix),
220            suffix_counter,
221        })
222    }
223
224    fn part(&self, index: usize) -> Option<PhpVersionPart> {
225        let component_count = usize::from(self.component_count);
226        if index < component_count {
227            return Some(PhpVersionPart::Number(self.components[index]));
228        }
229        if index == component_count {
230            return self.suffix.map(PhpVersionPart::Special);
231        }
232        if index == component_count + 1 && self.suffix.is_some() {
233            return self.suffix_counter.map(PhpVersionPart::Number);
234        }
235        None
236    }
237
238    fn compare(&self, other: &Self) -> Ordering {
239        let mut index = 0_usize;
240        loop {
241            match (self.part(index), other.part(index)) {
242                (Some(left), Some(right)) => {
243                    let ordering = compare_php_parts(left, right);
244                    if ordering != Ordering::Equal {
245                        return ordering;
246                    }
247                    index += 1;
248                },
249                (None, None) => return Ordering::Equal,
250                (Some(PhpVersionPart::Number(_)), None) => return Ordering::Greater,
251                (None, Some(PhpVersionPart::Number(_))) => return Ordering::Less,
252                (Some(PhpVersionPart::Special(stage)), None) => {
253                    return stage.rank().cmp(&PHP_FINAL_FORM_RANK);
254                },
255                (None, Some(PhpVersionPart::Special(stage))) => {
256                    return PHP_FINAL_FORM_RANK.cmp(&stage.rank());
257                },
258            }
259        }
260    }
261}
262
263impl PartialEq for PhpReleaseSubsetVersion {
264    fn eq(&self, other: &Self) -> bool {
265        self.compare(other) == Ordering::Equal
266    }
267}
268
269impl Eq for PhpReleaseSubsetVersion {}
270
271impl Ord for PhpReleaseSubsetVersion {
272    fn cmp(&self, other: &Self) -> Ordering {
273        self.compare(other)
274    }
275}
276
277impl PartialOrd for PhpReleaseSubsetVersion {
278    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
279        Some(self.cmp(other))
280    }
281}
282
283#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
284#[error("unsupported PHP release-subset WordPress version")]
285pub struct PhpReleaseSubsetVersionError;
286
287const PHP_FINAL_FORM_RANK: u8 = 4;
288
289#[derive(Clone, Copy)]
290enum PhpVersionPart {
291    Number(u32),
292    Special(PhpReleaseStage),
293}
294
295fn compare_php_parts(left: PhpVersionPart, right: PhpVersionPart) -> Ordering {
296    match (left, right) {
297        (PhpVersionPart::Number(left), PhpVersionPart::Number(right)) => left.cmp(&right),
298        (PhpVersionPart::Special(left), PhpVersionPart::Special(right)) => {
299            left.rank().cmp(&right.rank())
300        },
301        (PhpVersionPart::Number(_), PhpVersionPart::Special(right)) => {
302            PHP_FINAL_FORM_RANK.cmp(&right.rank())
303        },
304        (PhpVersionPart::Special(left), PhpVersionPart::Number(_)) => {
305            left.rank().cmp(&PHP_FINAL_FORM_RANK)
306        },
307    }
308}
309
310fn parse_php_number(bytes: &[u8], cursor: &mut usize) -> Result<u32, PhpReleaseSubsetVersionError> {
311    let start = *cursor;
312    let mut value = 0_u32;
313    while *cursor < bytes.len() && bytes[*cursor].is_ascii_digit() {
314        if *cursor - start == MAX_PHP_NUMERIC_DIGITS {
315            return Err(PhpReleaseSubsetVersionError);
316        }
317        value = value
318            .checked_mul(10)
319            .and_then(|value| value.checked_add(u32::from(bytes[*cursor] - b'0')))
320            .filter(|value| *value <= MAX_PHP_NUMERIC_VALUE)
321            .ok_or(PhpReleaseSubsetVersionError)?;
322        *cursor += 1;
323    }
324    if *cursor == start {
325        return Err(PhpReleaseSubsetVersionError);
326    }
327    Ok(value)
328}
329
330fn parse_php_suffix(
331    bytes: &[u8],
332    cursor: &mut usize,
333) -> Result<PhpReleaseStage, PhpReleaseSubsetVersionError> {
334    let remaining = &bytes[*cursor..];
335    let (stage, length) = if remaining.starts_with(b"alpha") {
336        (PhpReleaseStage::Alpha, 5)
337    } else if remaining.starts_with(b"beta") {
338        (PhpReleaseStage::Beta, 4)
339    } else if remaining.starts_with(b"dev") {
340        (PhpReleaseStage::Dev, 3)
341    } else if remaining.starts_with(b"RC") || remaining.starts_with(b"rc") {
342        (PhpReleaseStage::Rc, 2)
343    } else if remaining.starts_with(b"pl") {
344        (PhpReleaseStage::PatchLevel, 2)
345    } else if remaining.starts_with(b"a") {
346        (PhpReleaseStage::Alpha, 1)
347    } else if remaining.starts_with(b"b") {
348        (PhpReleaseStage::Beta, 1)
349    } else if remaining.starts_with(b"p") {
350        (PhpReleaseStage::PatchLevel, 1)
351    } else {
352        return Err(PhpReleaseSubsetVersionError);
353    };
354    *cursor += length;
355    Ok(stage)
356}
357
358const fn is_php_separator(byte: u8) -> bool {
359    matches!(byte, b'.' | b'-' | b'_' | b'+')
360}
361
362#[derive(Clone, Debug, Eq, PartialEq)]
363pub(crate) enum ProfiledVersionKey {
364    NumericDotted(NumericDottedVersion),
365    PhpReleaseSubset(PhpReleaseSubsetVersion),
366}
367
368impl ProfiledVersionKey {
369    pub(crate) fn parse(
370        profile: WordPressComparisonProfile,
371        value: &str,
372    ) -> Result<Self, ProfiledVersionError> {
373        match profile {
374            WordPressComparisonProfile::NumericDottedV1 => NumericDottedVersion::parse(value)
375                .map(Self::NumericDotted)
376                .map_err(|_| ProfiledVersionError),
377            WordPressComparisonProfile::PhpReleaseSubsetV1 => PhpReleaseSubsetVersion::parse(value)
378                .map(Self::PhpReleaseSubset)
379                .map_err(|_| ProfiledVersionError),
380        }
381    }
382
383    pub(crate) fn compare(&self, other: &Self) -> Result<Ordering, ProfiledVersionError> {
384        match (self, other) {
385            (Self::NumericDotted(left), Self::NumericDotted(right)) => Ok(left.cmp(right)),
386            (Self::PhpReleaseSubset(left), Self::PhpReleaseSubset(right)) => Ok(left.cmp(right)),
387            _ => Err(ProfiledVersionError),
388        }
389    }
390}
391
392#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
393#[error("versions do not belong to one supported comparison profile")]
394pub(crate) struct ProfiledVersionError;
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn php_subset_matches_reviewed_upstream_known_answers() {
402        for (left, right, expected) in [
403            ("1.0", "1.0.0", Ordering::Less),
404            ("2.4.0-beta1", "2.4.0", Ordering::Less),
405            ("1.0RC1", "1.0rc1", Ordering::Equal),
406            ("1.0-beta2", "1.0-beta10", Ordering::Less),
407            ("1.0", "1.0pl1", Ordering::Less),
408            ("1.0-alpha1", "1.0a1", Ordering::Equal),
409            ("1.0-beta", "1.0-beta0", Ordering::Less),
410            ("1.0+beta1", "1.0-beta1", Ordering::Equal),
411            ("1.0+1", "1.0.1", Ordering::Equal),
412            ("1.01", "1.1", Ordering::Equal),
413            ("1.0.1", "1.0pl1", Ordering::Less),
414            ("1.0-dev1", "1.0alpha1", Ordering::Less),
415            ("1.0alpha1", "1.0-beta1", Ordering::Less),
416            ("1.0beta1", "1.0RC1", Ordering::Less),
417            ("1.0rc1", "1.0", Ordering::Less),
418            ("1.0", "1.0-p1", Ordering::Less),
419            ("1_0_rc_1", "1.0RC1", Ordering::Equal),
420            ("1.0p1", "1.0pl1", Ordering::Equal),
421            ("1.0dev", "1.0dev0", Ordering::Less),
422        ] {
423            let left = PhpReleaseSubsetVersion::parse(left).unwrap();
424            let right = PhpReleaseSubsetVersion::parse(right).unwrap();
425            assert_eq!(left.cmp(&right), expected);
426            assert_eq!(right.cmp(&left), expected.reverse());
427        }
428    }
429
430    #[test]
431    fn php_subset_rejects_values_outside_the_documented_grammar() {
432        for value in [
433            "",
434            "v1.0",
435            "1.0-stable",
436            "1.0-ALPHA1",
437            "1.0-Beta1",
438            "1..0",
439            "1.0-",
440            "1.0--beta1",
441            "1.0beta1pl1",
442            "1.0 beta1",
443            "1.0\0beta1",
444            "1.0é",
445            "beta2",
446            "alpha1",
447            "RC1",
448            "pl1",
449            "1.0#N#",
450            "1.0+vendor1",
451            "2147483648",
452            "00000000001",
453            "1.2.3.4.5.6.7.8.9",
454        ] {
455            assert_eq!(
456                PhpReleaseSubsetVersion::parse(value),
457                Err(PhpReleaseSubsetVersionError),
458                "unexpectedly accepted {value:?}"
459            );
460        }
461    }
462
463    #[test]
464    fn profiles_keep_trailing_zero_semantics_distinct() {
465        assert_eq!(
466            NumericDottedVersion::parse("1.0").unwrap(),
467            NumericDottedVersion::parse("1.0.0").unwrap()
468        );
469        assert!(
470            PhpReleaseSubsetVersion::parse("1.0").unwrap()
471                < PhpReleaseSubsetVersion::parse("1.0.0").unwrap()
472        );
473        assert_eq!(
474            PhpReleaseSubsetVersion::parse("01.002").unwrap(),
475            PhpReleaseSubsetVersion::parse("1.2").unwrap()
476        );
477        assert!(PhpReleaseSubsetVersion::parse("2147483647").is_ok());
478        assert!(PhpReleaseSubsetVersion::parse("1.2.3.4.5.6.7.8").is_ok());
479    }
480
481    #[test]
482    fn semantic_aliases_deduplicate_and_profiles_never_compare_implicitly() {
483        use std::collections::BTreeSet;
484
485        let aliases = ["1.0-alpha1", "1.0a1", "1_0_a_01"]
486            .map(|value| PhpReleaseSubsetVersion::parse(value).unwrap())
487            .into_iter()
488            .collect::<BTreeSet<_>>();
489        assert_eq!(aliases.len(), 1);
490
491        let numeric =
492            ProfiledVersionKey::parse(WordPressComparisonProfile::NumericDottedV1, "1.0").unwrap();
493        let php = ProfiledVersionKey::parse(WordPressComparisonProfile::PhpReleaseSubsetV1, "1.0")
494            .unwrap();
495        let php_alias =
496            ProfiledVersionKey::parse(WordPressComparisonProfile::PhpReleaseSubsetV1, "1_0_a_01")
497                .unwrap();
498        let php_canonical =
499            ProfiledVersionKey::parse(WordPressComparisonProfile::PhpReleaseSubsetV1, "1.0-alpha1")
500                .unwrap();
501        assert_eq!(php_alias, php_canonical);
502        assert_ne!(numeric, php);
503        assert_eq!(numeric.compare(&php), Err(ProfiledVersionError));
504    }
505
506    #[test]
507    fn accepted_php_subset_order_is_reflexive_antisymmetric_and_transitive() {
508        let values = [
509            "1.0dev1",
510            "1.0alpha1",
511            "1.0beta1",
512            "1.0RC1",
513            "1.0",
514            "1.0.1",
515            "1.0pl1",
516        ]
517        .map(|value| PhpReleaseSubsetVersion::parse(value).unwrap());
518        for value in &values {
519            assert_eq!(value.cmp(value), Ordering::Equal);
520        }
521        for pair in values.windows(2) {
522            assert!(pair[0] < pair[1]);
523            assert!(pair[1] > pair[0]);
524        }
525        for left in 0..values.len() {
526            for middle in 0..values.len() {
527                for right in 0..values.len() {
528                    if values[left] <= values[middle] && values[middle] <= values[right] {
529                        assert!(values[left] <= values[right]);
530                    }
531                }
532            }
533        }
534    }
535
536    #[test]
537    fn external_interpretation_work_charges_the_patched_range_product() {
538        assert_eq!(checked_external_interpretation_work(0, 0), Some(0));
539        assert_eq!(checked_external_interpretation_work(64, 0), Some(64));
540        assert_eq!(checked_external_interpretation_work(0, 64), Some(64));
541        assert_eq!(checked_external_interpretation_work(64, 64), Some(4_224));
542        assert_eq!(checked_external_interpretation_work(usize::MAX, 1), None);
543        assert_eq!(
544            checked_external_interpretation_work(usize::MAX / 2 + 1, 2),
545            None
546        );
547        assert_eq!(
548            checked_accumulate_external_interpretation_work(95_040, 64, 64, 98_304),
549            None
550        );
551        assert_eq!(
552            checked_accumulate_external_interpretation_work(90_000, 64, 64, 98_304),
553            Some(94_224)
554        );
555    }
556}