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}