Skip to main content

kagami/
normalize_version.rs

1//! Version ranges of GitHub repository advisories are written by hand, so they come in many shapes:
2//! `>= v1.19.0`, `All`, `1.2.0 through 1.8.7`, `>= 2.1.0 < 2.2.7`, `<=4.1.135.Final`,
3//! `1.20.1 and earlier`, `stable <= 2.8.13; beta <= 3.0.0.beta15`, ...
4//!
5//! `normalize_version_range` rewrites the shapes whose meaning is clear into the syntax that
6//! `semver::VersionReq` accepts; single versions are read by `lenient_semver`. It works as a list of small rules that are applied from top to
7//! bottom; every rule is a function with an example in its comment. A shape no rule understands
8//! comes out still unparsable, so that the caller fails to parse it and warns about it.
9//!
10//! Output format: one space between an operator and its version, `, ` between the comparators of
11//! one requirement (AND) and ` || ` between alternatives (OR). A range that already parses is
12//! returned unchanged, except a list of upper bounds like `< 1.8.4, < 1.9.1`, which is read as
13//! release branches.
14
15use crate::normalize_version_words::{
16    replace_ignore_case, replace_unusual_characters, words_to_operators,
17};
18use itertools::Itertools;
19use regex::Regex;
20use semver::{Comparator, Op, VersionReq};
21
22/// Rewrites a hand-written version range into the syntax `semver::VersionReq` accepts.
23///   `>= v1.19.0, < v1.19.6`                ->  `>= 1.19.0, < 1.19.6`
24///   `1.2.0 through 1.8.7`                  ->  `>= 1.2.0, <= 1.8.7`
25///   `>= 2.1.0 < 2.2.7,  >= 2.3.0 < 2.3.4`  ->  `>= 2.1.0, < 2.2.7 || >= 2.3.0, < 2.3.4`
26///   `< 1.8.4, < 1.9.1`                     ->  `>= 1.8.0, < 1.8.4 || >= 1.9.0, < 1.9.1`
27///   `v1.18.0,v1.18.1`                      ->  `= 1.18.0 || = 1.18.1`
28///   `All`                                  ->  `*`
29///   `>= 1.0.0, < 1.2.6`                    ->  `>= 1.0.0, < 1.2.6`  (already valid: unchanged)
30///   `NONE`                                 ->  `= NONE`  (still unparsable: the caller warns)
31pub fn normalize_version_range(range: impl Into<String>) -> String {
32    let range = range.into();
33    let original = range.clone();
34
35    if parses(&range) {
36        return upper_bound_lists_to_branches(&range);
37    }
38
39    let range = replace_unusual_characters(&range);
40    let range = drop_notes_in_parentheses(&range);
41    let range = drop_v_prefixes(&range);
42
43    if is_every_version(&range) {
44        return "*".to_string();
45    }
46
47    let range = words_to_operators(&range);
48
49    let normalized = range
50        .split("||")
51        .map(normalize_alternative)
52        .filter(|alternative| !alternative.is_empty())
53        .join(" || ");
54
55    if normalized.is_empty() {
56        // Nothing usable was left, for example `(confirmed on main @ e0f8d9b)`. The caller gets
57        // the original text, so that its warning shows what the advisory actually said.
58        return original.to_string();
59    }
60
61    normalized
62}
63
64// ----- the whole range -----
65
66// Whether `semver::VersionReq` accepts every alternative of the range as it is.
67//   `>= 1.0.0, < 1.2.6 || = 2.0.0`  ->  true
68//   `< 1.8.4, < 1.9.1`              ->  true
69//   `>= v1.0.0`                     ->  false
70fn parses(range: &str) -> bool {
71    range
72        .split("||")
73        .all(|alternative| VersionReq::parse(alternative.trim()).is_ok())
74}
75
76// Removes a note written in parentheses.
77//   `>= 4.0 (32-bit only)`           ->  `>= 4.0`
78//   `All versions (default config)`  ->  `All versions`
79//   `(confirmed on main @ e0f8d9b)`  ->  `` (nothing is left)
80fn drop_notes_in_parentheses(range: &str) -> String {
81    let without_note = match (range.find('('), range.rfind(')')) {
82        (Some(open), Some(close)) if open < close => {
83            format!("{}{}", &range[..open], &range[close + 1..])
84        }
85        (Some(open), _) => range[..open].to_string(),
86        _ => range.to_string(),
87    };
88    without_note.trim().to_string()
89}
90
91// Removes the `v` or `V` written in front of a version.
92//   `>= v1.19.0, < V1.19.6`  ->  `>= 1.19.0, < 1.19.6`
93//   `v2.9.3+6eba5be`         ->  `2.9.3+6eba5be`
94//   `>= 1.0.0-dev1`          ->  `>= 1.0.0-dev1`  (the `v` of `dev1` is inside a word)
95fn drop_v_prefixes(range: &str) -> String {
96    let v_before_digit = Regex::new(r"\b[vV](\d)").expect("the pattern is a constant");
97    v_before_digit.replace_all(range, "$1").to_string()
98}
99
100// Whether the range is a way of saying "every version".
101//   `All`                        ->  true
102//   `all versions`               ->  true
103//   `All versions prior to 1.7`  ->  false
104fn is_every_version(range: &str) -> bool {
105    let lowercase = range.trim().to_ascii_lowercase();
106    lowercase == "all" || lowercase == "all versions"
107}
108
109// Puts the same operator in front of every version of a list. A phrase that was repeated after
110// each version is removed first.
111//   (`1.14.2, 1.13.2 and 1.12.4`, `<=`, ``)                   ->  `<= 1.14.2, <= 1.13.2, <= 1.12.4`
112//   (`7.2.10 and below, 8.0.5 and below`, `<=`, `and below`)  ->  `<= 7.2.10, <= 8.0.5`
113//   (`1.7.14 and 1.8.7`, `<`, ``)                             ->  `< 1.7.14, < 1.8.7`
114pub fn bound_every_version(versions: &str, operator: &str, repeated_phrase: &str) -> String {
115    let versions = if repeated_phrase.is_empty() {
116        versions.to_string()
117    } else {
118        replace_ignore_case(versions, &format!(" {repeated_phrase}"), "")
119    };
120    let versions = replace_ignore_case(&versions, " and ", ",");
121    versions
122        .split(',')
123        .map(str::trim)
124        .filter(|version| !version.is_empty())
125        .map(|version| format!("{operator} {version}"))
126        .join(", ")
127}
128
129// Reads a range written like a mathematical interval.
130//   `2.2.0 <= V <= 2.10.1`          ->  Some(`>= 2.2.0, <= 2.10.1`)
131//   `2.0.0 < version <= 2.7.1`      ->  Some(`> 2.0.0, <= 2.7.1`)
132//   `1.22 < 1.27.1`                 ->  Some(`>= 1.22, < 1.27.1`)
133//   `>= 1.0.0, < 1.2.6`             ->  None
134//   `16.0.0, <= 15.0.1, <= 13.1.2`  ->  None  (the middle word is a version, not a variable)
135pub fn interval_to_requirement(range: &str) -> Option<String> {
136    let words = range.split_whitespace().collect::<Vec<_>>();
137
138    let looks_like_version =
139        |word: &str| word.starts_with(|c: char| c.is_ascii_digit()) && !word.contains(',');
140    let looks_like_variable = |word: &str| word.starts_with(|c: char| c.is_ascii_alphabetic());
141    let is_less_than = |word: &str| word == "<" || word == "<=";
142    let flipped = |operator: &str| if operator == "<" { ">" } else { ">=" };
143
144    match words.as_slice() {
145        [from, lower_operator, variable, upper_operator, to]
146            if looks_like_version(from)
147                && looks_like_version(to)
148                && looks_like_variable(variable)
149                && is_less_than(lower_operator)
150                && is_less_than(upper_operator) =>
151        {
152            Some(format!(
153                "{} {from}, {upper_operator} {to}",
154                flipped(lower_operator)
155            ))
156        }
157        [from, operator, to]
158            if looks_like_version(from) && looks_like_version(to) && is_less_than(operator) =>
159        {
160            Some(format!(">= {from}, {operator} {to}"))
161        }
162        _ => None,
163    }
164}
165
166// ----- one alternative (the parts between `||`) -----
167
168// Rebuilds one alternative (the text between two `||`). Comma-separated items are the comparators
169// of one requirement, unless an item is a requirement by itself (a hyphen range, a `>= a < b` pair
170// or an exact version); then the items are alternatives. A word without a digit next to real items
171// is a label and is dropped.
172//   `>= 1.0.0, < 1.2.6`                                    ->  `>= 1.0.0, < 1.2.6`
173//   `>= 2.1.0 < 2.2.7, >= 2.3.0 < 2.3.4`                   ->  `>= 2.1.0, < 2.2.7 || >= 2.3.0, < 2.3.4`
174//   `3.0.0-rc1 - 3.2.0-rc1, 3.1.7`                         ->  `>= 3.0.0-rc1, <= 3.2.0-rc1 || = 3.1.7`
175//   `stable <= 2.7.10, beta, tests-passed <= 2.8.0.beta8`  ->  `<= 2.7.10, <= 2.8.0-beta8`
176//   `< 1.8.4, < 1.9.1`                                     ->  `>= 1.8.0, < 1.8.4 || >= 1.9.0, < 1.9.1`
177fn normalize_alternative(alternative: &str) -> String {
178    let items = alternative
179        .split(',')
180        .map(str::trim)
181        .filter(|item| !item.is_empty())
182        .collect::<Vec<_>>();
183
184    let has_digit = |item: &&str| item.chars().any(|c| c.is_ascii_digit());
185
186    let some_item_has_a_digit = items.iter().any(has_digit);
187    let requirements = items
188        .iter()
189        .filter(|item| !some_item_has_a_digit || has_digit(item))
190        .map(|item| item_to_comparators(item))
191        .collect::<Vec<_>>();
192
193    let every_item_is_one_comparator = requirements
194        .iter()
195        .all(|comparators| comparators.len() == 1 && !comparators[0].starts_with("= "));
196
197    if every_item_is_one_comparator {
198        return group_into_branches(requirements.into_iter().flatten().collect());
199    }
200
201    requirements
202        .into_iter()
203        .map(group_into_branches)
204        .join(" || ")
205}
206
207// Turns one comma-separated item into its comparators, each written as `operator version`.
208//   `stable <= 2.8.13`  ->  [`<= 2.8.13`]
209//   `3.2.0 - 3.3.8`     ->  [`>= 3.2.0`, `<= 3.3.8`]
210//   `>= 2.1.0 < 2.2.7`  ->  [`>= 2.1.0`, `< 2.2.7`]
211//   `2.9.3+6eba5be`     ->  [`= 2.9.3`]
212//   `<=4.1.135.Final`   ->  [`<= 4.1.135`]
213fn item_to_comparators(item: &str) -> Vec<String> {
214    let item = drop_label(item);
215
216    if let Some((from, to)) = item.split_once(" - ") {
217        let (from, to) = (from.trim(), to.trim());
218        let both_are_versions = [from, to]
219            .iter()
220            .all(|side| side.starts_with(|c: char| c.is_ascii_digit()));
221        if both_are_versions {
222            return vec![
223                format!(">= {}", fix_version(from)),
224                format!("<= {}", fix_version(to)),
225            ];
226        }
227    }
228
229    split_at_operators(&item)
230        .iter()
231        .map(|piece| format_comparator(piece))
232        .collect()
233}
234
235// Removes the words written in front of a comparator or version, like a release channel or a
236// product name. The words must stand on their own: `edge-24.6.2` keeps its `edge-`.
237//   `stable <= 2.8.13`    ->  `<= 2.8.13`
238//   `jaeger-ui < 1.31.0`  ->  `< 1.31.0`
239//   `and 3.4.1`           ->  `3.4.1`
240//   `edge-24.6.2`         ->  `edge-24.6.2`
241//   `1.3.x <= 1.3.17`     ->  `1.3.x <= 1.3.17`  (the label contains a digit)
242fn drop_label(item: &str) -> String {
243    let start = item
244        .find(|c: char| c.is_ascii_digit() || is_operator_char(c))
245        .unwrap_or(0);
246    let label = &item[..start];
247    let label_is_separate_words =
248        label.ends_with(' ') && !label.chars().any(|c| c.is_ascii_digit());
249    if label_is_separate_words {
250        item[start..].to_string()
251    } else {
252        item.to_string()
253    }
254}
255
256// Cuts an item into pieces, a new piece starting wherever an operator starts.
257//   `>= 2.1.0 < 2.2.7`  ->  [`>= 2.1.0`, `< 2.2.7`]
258//   `>=9.2.0 <9.2.10`   ->  [`>=9.2.0`, `<9.2.10`]
259//   `<=4.1.135.Final`   ->  [`<=4.1.135.Final`]
260//   `3.1.7`             ->  [`3.1.7`]
261fn split_at_operators(item: &str) -> Vec<String> {
262    let mut pieces: Vec<String> = vec![];
263    let mut previous_is_operator = true;
264    for c in item.chars() {
265        let starts_operator = is_operator_char(c) && !previous_is_operator;
266        if starts_operator || pieces.is_empty() {
267            pieces.push(String::new());
268        }
269        pieces.last_mut().unwrap().push(c);
270        previous_is_operator = is_operator_char(c);
271    }
272    pieces
273        .iter()
274        .map(|piece| piece.trim().to_string())
275        .filter(|piece| !piece.is_empty())
276        .collect()
277}
278
279// Writes one piece as `operator version` with the version fixed; a bare version becomes exact.
280//   `<=4.1.135.Final`  ->  `<= 4.1.135`
281//   `>= 19.03.15`      ->  `>= 19.3.15`
282//   `< 23.7.6.111`     ->  `< 23.7.6`
283//   `3.1.7`            ->  `= 3.1.7`
284//   `2.9.3+6eba5be`    ->  `= 2.9.3`
285fn format_comparator(piece: &str) -> String {
286    let operator_end = piece
287        .find(|c: char| !is_operator_char(c))
288        .unwrap_or(piece.len());
289    let (operator, version) = piece.split_at(operator_end);
290    let version = fix_version(version.trim());
291    if operator.is_empty() {
292        format!("= {version}")
293    } else {
294        format!("{operator} {version}")
295    }
296}
297
298// Whether the character can be part of an operator.
299//   `<`, `>`, `=`, `~`, `^`  ->  true
300//   `-`, `|`, `1`, ` `       ->  false
301fn is_operator_char(c: char) -> bool {
302    matches!(c, '<' | '>' | '=' | '~' | '^')
303}
304
305// ----- one version -----
306
307// Rewrites a version that semver does not accept into one it does, with `lenient_semver` doing
308// the reading. Build metadata is dropped because a requirement cannot carry it.
309//   `4.1.135.Final`     ->  `4.1.135`      (Maven's release qualifier is metadata to lenient_semver)
310//   `3.0.0.beta15`      ->  `3.0.0-beta15`  (a word after the patch number is a pre-release)
311//   `0.23.0rc2`         ->  `0.23.0-0rc2`
312//   `19.03.15`          ->  `19.3.15`
313//   `2.9.3+6eba5be`     ->  `2.9.3`
314//   `23.7.6.111`        ->  `23.7.6`  (a fourth number has no place in semver and is dropped)
315//   `9.4.37.v20210219`  ->  `9.4.37`  (Jetty's build stamp is not a pre-release)
316//   `7.2`               ->  `7.2`     (what `VersionReq` already accepts is left alone)
317//   `3.5-5.9`           ->  `3.5-5.9` (a hyphen could be a pre-release or a range: left alone)
318fn fix_version(version: &str) -> String {
319    // `VersionReq` accepts build metadata but ignores it, so it goes right away.
320    let version = version.split('+').next().unwrap_or(version);
321
322    let already_accepted = VersionReq::parse(version).is_ok();
323    let has_hyphen = version.contains('-');
324    if already_accepted || has_hyphen {
325        return version.to_string();
326    }
327
328    let build_stamp = Regex::new(r"^(\d+\.\d+\.\d+)\.v\d+$").expect("the pattern is a constant");
329    let without_stamp = build_stamp.replace(version, "$1");
330
331    match lenient_semver::parse(&without_stamp) {
332        Ok(parsed) => {
333            let mut fixed = format!("{}.{}.{}", parsed.major, parsed.minor, parsed.patch);
334            if !parsed.pre.is_empty() {
335                fixed = format!("{fixed}-{}", parsed.pre);
336            }
337            fixed
338        }
339        Err(_) => version.to_string(),
340    }
341}
342
343// ----- release branches -----
344
345// Joins comparators into requirements. A lower bound starts a new release branch, and a list of
346// upper bounds only is read as one branch per upper bound.
347//   [`>= 1.0.0`, `< 1.2.6`]                         ->  `>= 1.0.0, < 1.2.6`
348//   [`>= 2.1.0`, `< 2.2.7`, `>= 2.3.0`, `< 2.3.4`]  ->  `>= 2.1.0, < 2.2.7 || >= 2.3.0, < 2.3.4`
349//   [`< 3.5.3`, `>= 3.6.0`, `< 3.11.3`]             ->  `< 3.5.3 || >= 3.6.0, < 3.11.3`
350//   [`< 1.8.4`, `< 1.9.1`]                          ->  `>= 1.8.0, < 1.8.4 || >= 1.9.0, < 1.9.1`
351fn group_into_branches(comparators: Vec<String>) -> String {
352    let mut branches: Vec<Vec<String>> = vec![];
353
354    for comparator in comparators {
355        let starts_new_branch = comparator.starts_with('>') && !branches.is_empty();
356        if starts_new_branch || branches.is_empty() {
357            branches.push(vec![]);
358        }
359        branches.last_mut().unwrap().push(comparator);
360    }
361
362    branches
363        .iter()
364        .map(|branch| {
365            let requirement = branch.join(", ");
366            upper_bounds_to_branches(&requirement).unwrap_or(requirement)
367        })
368        .join(" || ")
369}
370
371// For a range that already parses: returns it unchanged, except that a list of upper bounds is
372// read as release branches.
373//   `>= 1.0.0, < 1.2.6`            ->  `>= 1.0.0, < 1.2.6`
374//   `< 1.8.4, < 1.9.1`             ->  `>= 1.8.0, < 1.8.4 || >= 1.9.0, < 1.9.1`
375//   `< 1.0.0 || < 1.8.4, < 1.9.1`  ->  `< 1.0.0 || >= 1.8.0, < 1.8.4 || >= 1.9.0, < 1.9.1`
376fn upper_bound_lists_to_branches(range: &str) -> String {
377    let alternatives = range.split("||").map(str::trim).collect::<Vec<_>>();
378    let has_upper_bound_list = alternatives
379        .iter()
380        .any(|alternative| upper_bounds_to_branches(alternative).is_some());
381
382    if !has_upper_bound_list {
383        return range.to_string();
384    }
385
386    alternatives
387        .iter()
388        .map(|alternative| {
389            upper_bounds_to_branches(alternative).unwrap_or_else(|| alternative.to_string())
390        })
391        .join(" || ")
392}
393
394// Reads a requirement made of upper bounds only, like `< 1.8.4, < 1.9.1`, as the fix of several
395// release branches. Literally the comma is AND, which would drop the 1.9 branch, so every upper
396// bound gets its own branch as lower bound. Returns None for any other requirement.
397//   `< 1.8.4, < 1.9.1`         ->  Some(`>= 1.8.0, < 1.8.4 || >= 1.9.0, < 1.9.1`)
398//   `<= 4.2.12, <= 4.1.132`    ->  Some(`>= 4.2.0, <= 4.2.12 || >= 4.1.0, <= 4.1.132`)
399//   `<8.4.26`                  ->  None  (one bound is kept literal)
400//   `< 1.8.4, < 1.8.9`         ->  None  (same branch twice)
401//   `<= 1.4.0-rc.2, <= 1.3.5`  ->  None  (pre-release)
402//   `>= 1.0.0, < 1.2.6`        ->  None  (has a lower bound)
403fn upper_bounds_to_branches(requirement: &str) -> Option<String> {
404    let parsed = VersionReq::parse(requirement).ok()?;
405    let comparators = &parsed.comparators;
406
407    let all_upper_bounds = comparators
408        .iter()
409        .all(|c| matches!(c.op, Op::Less | Op::LessEq));
410    let all_full_versions = comparators
411        .iter()
412        .all(|c| c.minor.is_some() && c.patch.is_some() && c.pre.is_empty());
413    let all_different_branches = comparators
414        .iter()
415        .map(|c| (c.major, c.minor))
416        .unique()
417        .count()
418        == comparators.len();
419
420    let is_upper_bound_list =
421        comparators.len() >= 2 && all_upper_bounds && all_full_versions && all_different_branches;
422
423    if !is_upper_bound_list {
424        return None;
425    }
426
427    Some(comparators.iter().map(upper_bound_to_branch).join(" || "))
428}
429
430// Gives an upper bound the start of its release branch as lower bound.
431//   `< 1.8.4`    ->  `>= 1.8.0, < 1.8.4`
432//   `<= 4.2.12`  ->  `>= 4.2.0, <= 4.2.12`
433fn upper_bound_to_branch(comparator: &Comparator) -> String {
434    let operator = if comparator.op == Op::LessEq {
435        "<="
436    } else {
437        "<"
438    };
439    let (major, minor) = (comparator.major, comparator.minor.unwrap());
440    let patch = comparator.patch.unwrap();
441    format!(">= {major}.{minor}.0, {operator} {major}.{minor}.{patch}")
442}