1use std::cmp::Ordering;
10
11use thiserror::Error;
12
13pub(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#[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}