Skip to content

g1t/services/security/src/ranges.rs

329 lines13,189 bytesCodeBlame
1//! Version requirements, as `ignore` rules and manifests write them in
2//! each ecosystem's own syntax: npm's `^1.2.0 || 2.x`, Cargo's `1.2`
3//! (a caret), pip's `~=1.4, !=1.4.2`, Bundler's `~> 2.0`, NuGet's `7.*`,
4//! Maven's `[1.4,)`. And what kind of update one version is from another.
5
6use std::cmp::Ordering;
7
8use g1t_scan::version;
9
10/// How a bare version, with no operator, is read.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum Bare {
13 /// That version exactly (npm, pip, an `ignore` rule).
14 Exact,
15 /// A caret requirement (Cargo).
16 Caret,
17}
18
19#[derive(Clone, Debug, PartialEq)]
20enum Bound {
21 Min(String, bool),
22 Max(String, bool),
23 Not(String),
24 Exact(String),
25}
26
27/// The leading numbers of a version: `v1.2.3-rc.1` is `[1, 2, 3]`.
28fn numbers(text: &str) -> Vec<u64> {
29 let text = text.trim().trim_start_matches(['v', 'V', '=']);
30 let release = text.split(['-', '+']).next().unwrap_or(text);
31 let mut out = Vec::new();
32 for part in release.split('.') {
33 let digits: String = part.chars().take_while(char::is_ascii_digit).collect();
34 if digits.is_empty() {
35 break;
36 }
37 out.push(digits.parse().unwrap_or(u64::MAX));
38 if digits.len() != part.len() {
39 break;
40 }
41 }
42 out
43}
44
45fn join(parts: &[u64]) -> String {
46 parts.iter().map(u64::to_string).collect::<Vec<_>>().join(".")
47}
48
49/// The version just past every one starting with `parts`: `[1, 2]` → `1.3`.
50fn next_after(parts: &[u64]) -> String {
51 let mut parts = parts.to_vec();
52 if let Some(last) = parts.last_mut() {
53 *last += 1;
54 }
55 join(&parts)
56}
57
58/// A version written with wildcards: `1.2.x`, `1.*`, `*`. Returns the
59/// fixed numbers before the first wildcard.
60fn wildcard(text: &str) -> Option<Vec<u64>> {
61 let text = text.trim().trim_start_matches(['v', '=']);
62 let parts: Vec<&str> = text.split('.').collect();
63 let at = parts.iter().position(|part| matches!(*part, "x" | "X" | "*"))?;
64 parts[..at].iter().map(|part| part.parse().ok()).collect()
65}
66
67/// The bounds one comparator sets.
68fn comparator(text: &str, bare: Bare) -> Option<Vec<Bound>> {
69 let text = text.trim();
70 let operators = ["===", "==", ">=", "<=", "!=", "~>", "~=", "^", "~", ">", "<", "="];
71 let (operator, rest) = operators
72 .iter()
73 .find_map(|op| text.strip_prefix(op).map(|rest| (*op, rest.trim())))
74 .unwrap_or(("", text));
75 if rest.is_empty() {
76 return None;
77 }
78 if let Some(fixed) = wildcard(rest) {
79 let low = join(&fixed);
80 return Some(match operator {
81 "" | "=" | "==" | "===" | "^" | "~" => {
82 if fixed.is_empty() {
83 vec![]
84 } else {
85 vec![Bound::Min(low, true), Bound::Max(next_after(&fixed), false)]
86 }
87 }
88 "!=" => vec![Bound::Not(rest.to_owned())],
89 ">=" | ">" => vec![Bound::Min(low, true)],
90 "<" | "<=" => vec![Bound::Max(low, false)],
91 _ => return None,
92 });
93 }
94 let parts = numbers(rest);
95 if parts.is_empty() {
96 return None;
97 }
98 let version = rest.to_owned();
99 let caret = |parts: &[u64]| -> Vec<Bound> {
100 // The first non-zero part may not change; `^0.0` and `^0` hold their zeros.
101 let keep = parts.iter().position(|part| *part != 0).map_or(parts.len().max(1), |at| at + 1).min(parts.len());
102 vec![Bound::Min(version.clone(), true), Bound::Max(next_after(&parts[..keep]), false)]
103 };
104 Some(match operator {
105 "" if bare == Bare::Caret => caret(&parts),
106 "" | "=" | "==" | "===" => vec![Bound::Exact(version)],
107 "^" => caret(&parts),
108 "~" => {
109 let keep = if parts.len() >= 2 { 2 } else { 1 };
110 vec![Bound::Min(version.clone(), true), Bound::Max(next_after(&parts[..keep]), false)]
111 }
112 "~>" | "~=" => {
113 let keep = parts.len().saturating_sub(1).max(1);
114 vec![Bound::Min(version.clone(), true), Bound::Max(next_after(&parts[..keep]), false)]
115 }
116 ">=" => vec![Bound::Min(version, true)],
117 ">" => vec![Bound::Min(version, false)],
118 "<=" => vec![Bound::Max(version, true)],
119 "<" => vec![Bound::Max(version, false)],
120 "!=" => vec![Bound::Not(version)],
121 _ => return None,
122 })
123}
124
125/// Maven's and NuGet's interval notation: `[1.0,2.0)`, `(,1.0]`, `[1.5,)`, `[1.0]`.
126fn interval(text: &str) -> Option<Vec<Bound>> {
127 let text = text.trim();
128 let open = text.chars().next().filter(|c| matches!(c, '[' | '('))?;
129 let close = text.chars().last().filter(|c| matches!(c, ']' | ')'))?;
130 let inner = &text[1..text.len() - 1];
131 let Some((low, high)) = inner.split_once(',') else {
132 return (open == '[' && close == ']').then(|| vec![Bound::Exact(inner.trim().to_owned())]);
133 };
134 let mut bounds = Vec::new();
135 if !low.trim().is_empty() {
136 bounds.push(Bound::Min(low.trim().to_owned(), open == '['));
137 }
138 if !high.trim().is_empty() {
139 bounds.push(Bound::Max(high.trim().to_owned(), close == ']'));
140 }
141 Some(bounds)
142}
143
144/// One set of bounds that must all hold, from `>=1.2 <2`, `>= 1.2, < 2` or `1.2 - 2.3`.
145fn conjunction(text: &str, bare: Bare) -> Option<Vec<Bound>> {
146 let text = text.trim();
147 if text.is_empty() || text == "*" || text.eq_ignore_ascii_case("latest") {
148 return Some(Vec::new());
149 }
150 if let Some(bounds) = interval(text) {
151 return Some(bounds);
152 }
153 if let Some((low, high)) = text.split_once(" - ") {
154 let high_parts = numbers(high);
155 let max = if high.trim().split('.').count() < 3 && !high_parts.is_empty() {
156 Bound::Max(next_after(&high_parts), false)
157 } else {
158 Bound::Max(high.trim().to_owned(), true)
159 };
160 return Some(vec![Bound::Min(low.trim().to_owned(), true), max]);
161 }
162 // Operators may be followed by a space: `>= 1.2, < 2`.
163 let mut tokens: Vec<String> = Vec::new();
164 for word in text.split([',', ' ']).filter(|word| !word.is_empty()) {
165 match tokens.last_mut() {
166 Some(last) if last.chars().all(|c| "<>=!~^".contains(c)) => last.push_str(word),
167 _ => tokens.push(word.to_owned()),
168 }
169 }
170 let mut bounds = Vec::new();
171 for token in tokens {
172 bounds.extend(comparator(&token, bare)?);
173 }
174 Some(bounds)
175}
176
177fn holds(bound: &Bound, candidate: &str) -> bool {
178 let order = version::compare(candidate, match bound {
179 Bound::Min(v, _) | Bound::Max(v, _) | Bound::Not(v) | Bound::Exact(v) => v,
180 });
181 match bound {
182 Bound::Min(_, inclusive) => order == Ordering::Greater || (*inclusive && order == Ordering::Equal),
183 Bound::Max(_, inclusive) => order == Ordering::Less || (*inclusive && order == Ordering::Equal),
184 Bound::Not(text) => match wildcard(text) {
185 Some(fixed) => !numbers(candidate).starts_with(&fixed),
186 None => order != Ordering::Equal,
187 },
188 Bound::Exact(_) => order == Ordering::Equal,
189 }
190}
191
192/// Whether `candidate` meets `requirement`. `None` when the requirement
193/// cannot be read.
194pub fn satisfies(requirement: &str, candidate: &str, bare: Bare) -> Option<bool> {
195 let mut any = false;
196 for alternative in requirement.split("||") {
197 let bounds = conjunction(alternative, bare)?;
198 any |= bounds.iter().all(|bound| holds(bound, candidate));
199 }
200 Some(any)
201}
202
203/// Whether an `ignore` rule's `versions` entry covers `candidate`. One
204/// that cannot be read covers the version it names, and nothing else.
205pub fn ignored_by(versions: &str, candidate: &str) -> bool {
206 satisfies(versions, candidate, Bare::Exact).unwrap_or_else(|| versions.trim() == candidate.trim())
207}
208
209/// `major`, `minor` or `patch`: the first of the three numbers that
210/// differs from `from` to `to`.
211pub fn update_level(from: &str, to: &str) -> &'static str {
212 let (a, b) = (numbers(from), numbers(to));
213 let at = |parts: &[u64], index: usize| parts.get(index).copied().unwrap_or(0);
214 if at(&a, 0) != at(&b, 0) {
215 "major"
216 } else if at(&a, 1) != at(&b, 1) {
217 "minor"
218 } else {
219 "patch"
220 }
221}
222
223/// `version-update:semver-<level>`.
224pub fn update_type(from: &str, to: &str) -> String {
225 format!("version-update:semver-{}", update_level(from, to))
226}
227
228/// The requirement an `@g1t ignore this <level> version` comment records,
229/// as an `ignore` rule's `versions` would say it: `>= 5.a, < 6` for a major
230/// version, `>= 5.1.a, < 5.2` for a minor one, `5.1.3` for a patch.
231pub fn ignore_level(to: &str, level: &str) -> String {
232 let parts = numbers(to);
233 let at = |index: usize| parts.get(index).copied().unwrap_or(0);
234 match level {
235 "major" => format!(">= {}.a, < {}", at(0), at(0) + 1),
236 "minor" => format!(">= {}.{}.a, < {}.{}", at(0), at(1), at(0), at(1) + 1),
237 _ => to.trim().to_owned(),
238 }
239}
240
241/// Whether a version is a pre-release: `2.0.0-rc.1`, `2.0.0b1`, `2.0.0.dev1`.
242pub fn prerelease(text: &str) -> bool {
243 let text = text.trim().trim_start_matches(['v', 'V']);
244 let release = text.split('+').next().unwrap_or(text);
245 release.contains('-')
246 || release
247 .split('.')
248 .any(|part| part.chars().any(|c| c.is_ascii_alphabetic()) && !part.starts_with("post") && !part.starts_with("final"))
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn npm(requirement: &str, candidate: &str) -> bool {
256 satisfies(requirement, candidate, Bare::Exact).unwrap()
257 }
258
259 #[test]
260 fn npm_ranges() {
261 assert!(npm("^1.2.3", "1.9.0") && !npm("^1.2.3", "2.0.0") && !npm("^1.2.3", "1.2.2"));
262 assert!(npm("^0.2.3", "0.2.9") && !npm("^0.2.3", "0.3.0"));
263 assert!(npm("^0.0.3", "0.0.3") && !npm("^0.0.3", "0.0.4"));
264 assert!(npm("~1.2.3", "1.2.9") && !npm("~1.2.3", "1.3.0"));
265 assert!(npm("1.2.x", "1.2.7") && !npm("1.2.x", "1.3.0"));
266 assert!(npm("1.x || >=3", "3.1.0") && npm("1.x || >=3", "1.4.0") && !npm("1.x || >=3", "2.0.0"));
267 assert!(npm(">=1.2 <2", "1.5.0") && !npm(">=1.2 <2", "2.0.0"));
268 assert!(npm("1.2 - 1.4", "1.4.9") && !npm("1.2 - 1.4", "1.5.0"));
269 assert!(npm("*", "9.9.9") && npm("", "1.0.0"));
270 assert!(npm("4.17.21", "4.17.21") && !npm("4.17.21", "4.17.20"));
271 }
272
273 #[test]
274 fn other_ecosystems() {
275 // Cargo: a bare version is a caret.
276 assert!(satisfies("1.2", "1.9.0", Bare::Caret).unwrap());
277 assert!(!satisfies("1.2", "2.0.0", Bare::Caret).unwrap());
278 assert!(satisfies("0.3", "0.3.7", Bare::Caret).unwrap() && !satisfies("0.3", "0.4.0", Bare::Caret).unwrap());
279 assert!(satisfies("=1.2.3", "1.2.3", Bare::Caret).unwrap());
280 // pip.
281 assert!(npm("~=1.4.2", "1.4.9") && !npm("~=1.4.2", "1.5.0"));
282 assert!(npm("~=1.4", "1.9") && !npm("~=1.4", "2.0"));
283 assert!(npm(">=2.0,!=2.1.0,<3", "2.2.0") && !npm(">=2.0,!=2.1.0,<3", "2.1.0"));
284 assert!(npm("==1.*", "1.5") && !npm("==1.*", "2.0"));
285 assert!(npm("!=1.*", "2.0") && !npm("!=1.*", "1.3"));
286 // Bundler.
287 assert!(npm("~> 2.0", "2.9") && !npm("~> 2.0", "3.0"));
288 assert!(npm("~> 2.0.1", "2.0.9") && !npm("~> 2.0.1", "2.1.0"));
289 // NuGet and Maven.
290 assert!(npm("7.*", "7.3.1") && !npm("7.*", "8.0.0"));
291 assert!(npm("[1.4,)", "1.4") && npm("[1.4,)", "3.0") && !npm("[1.4,)", "1.3"));
292 assert!(npm("[1.0,2.0)", "1.9.9") && !npm("[1.0,2.0)", "2.0"));
293 assert!(npm("(,1.0]", "1.0") && !npm("(,1.0]", "1.0.1"));
294 assert!(npm("[1.0]", "1.0") && !npm("[1.0]", "1.1"));
295 // An ignore rule written with spaces after operators.
296 assert!(npm(">= 5.a, < 6", "5.0.0") && npm(">= 5.a, < 6", "5.9.1") && !npm(">= 5.a, < 6", "6.0.0") && !npm(">= 5.a, < 6", "4.9.0"));
297 assert_eq!(satisfies("^", "1.0.0", Bare::Exact), None);
298 assert!(ignored_by("not a range", "not a range"));
299 }
300
301 #[test]
302 fn update_levels() {
303 assert_eq!(update_level("4.17.20", "4.17.21"), "patch");
304 assert_eq!(update_level("4.17.20", "4.18.0"), "minor");
305 assert_eq!(update_level("4.17.20", "5.0.0"), "major");
306 assert_eq!(update_level("v0.7.0", "v0.23.0"), "minor");
307 assert_eq!(update_level("1.2", "1.2.1"), "patch");
308 assert_eq!(update_type("1.0.0", "2.0.0"), "version-update:semver-major");
309 }
310
311 #[test]
312 fn ignore_comments_become_requirements() {
313 assert_eq!(ignore_level("5.1.3", "major"), ">= 5.a, < 6");
314 assert_eq!(ignore_level("5.1.3", "minor"), ">= 5.1.a, < 5.2");
315 assert_eq!(ignore_level("5.1.3", "patch"), "5.1.3");
316 assert!(ignored_by(&ignore_level("5.1.3", "minor"), "5.1.9"));
317 assert!(!ignored_by(&ignore_level("5.1.3", "minor"), "5.2.0"));
318 }
319
320 #[test]
321 fn prereleases() {
322 for pre in ["2.0.0-rc.1", "2.0.0b1", "2.0.0.dev1", "v1.0.0-alpha", "1.0a1"] {
323 assert!(prerelease(pre), "{pre}");
324 }
325 for release in ["2.0.0", "v1.2.3", "1.2.post1", "1.0.0+build.5"] {
326 assert!(!prerelease(release), "{release}");
327 }
328 }
329}