Skip to main content

mollify_core/
version.rs

1//! A pragmatic **PEP 440 subset** for matching package versions against
2//! advisory constraint ranges. Not a full PEP 440 implementation: it handles
3//! release segments (`1.2.3`), an optional pre-release tag (`a`/`b`/`rc`), and
4//! the operators `== != < <= > >= ~=`. Epochs, local versions, and `===` are
5//! out of scope (documented; we degrade to "no match" rather than guess).
6
7use std::cmp::Ordering;
8
9/// A parsed version: release components plus an optional pre-release rank.
10/// Pre-releases sort *before* the same release (`1.0rc1` < `1.0`).
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Version {
13    release: Vec<u64>,
14    /// `None` for a final release; `Some((rank, n))` for a pre-release where
15    /// rank orders a<b<rc (0,1,2). Final releases sort after all pre-releases.
16    pre: Option<(u8, u64)>,
17}
18
19impl Version {
20    /// Parse a version string. Returns `None` if no leading release segment.
21    pub fn parse(s: &str) -> Option<Version> {
22        let s = s.trim();
23        let s = s.strip_prefix('v').unwrap_or(s);
24        // Split off a local version (`+...`) — ignored for comparison.
25        let s = s.split('+').next().unwrap_or(s);
26        // Find where the release segment ends (first non-digit, non-dot char).
27        let end = s
28            .find(|c: char| !c.is_ascii_digit() && c != '.')
29            .unwrap_or(s.len());
30        let (rel_str, rest) = s.split_at(end);
31        let release: Vec<u64> = rel_str
32            .split('.')
33            .filter(|p| !p.is_empty())
34            .map(|p| p.parse::<u64>().ok())
35            .collect::<Option<Vec<_>>>()?;
36        if release.is_empty() {
37            return None;
38        }
39        let pre = parse_pre(rest);
40        Some(Version { release, pre })
41    }
42
43    /// Compare two versions per PEP 440 ordering (release then pre-release).
44    pub fn cmp_to(&self, other: &Version) -> Ordering {
45        let max = self.release.len().max(other.release.len());
46        for i in 0..max {
47            let a = self.release.get(i).copied().unwrap_or(0);
48            let b = other.release.get(i).copied().unwrap_or(0);
49            match a.cmp(&b) {
50                Ordering::Equal => continue,
51                ord => return ord,
52            }
53        }
54        // Equal release: a pre-release is less than a final release.
55        match (self.pre, other.pre) {
56            (None, None) => Ordering::Equal,
57            (Some(_), None) => Ordering::Less,
58            (None, Some(_)) => Ordering::Greater,
59            (Some(a), Some(b)) => a.cmp(&b),
60        }
61    }
62}
63
64/// Parse a pre-release suffix like `rc1`, `b2`, `a`, `.rc1`, `-beta.1`.
65fn parse_pre(rest: &str) -> Option<(u8, u64)> {
66    let r = rest
67        .trim_start_matches(['.', '-', '_'])
68        .to_ascii_lowercase();
69    let (rank, tail) = if let Some(t) = r.strip_prefix("alpha") {
70        (0u8, t)
71    } else if let Some(t) = r.strip_prefix('a') {
72        (0, t)
73    } else if let Some(t) = r.strip_prefix("beta") {
74        (1, t)
75    } else if let Some(t) = r.strip_prefix('b') {
76        (1, t)
77    } else if let Some(t) = r.strip_prefix("rc") {
78        (2, t)
79    } else if let Some(t) = r.strip_prefix("c") {
80        (2, t)
81    } else {
82        return None;
83    };
84    let n: u64 = tail
85        .trim_start_matches(['.', '-', '_'])
86        .chars()
87        .take_while(|c| c.is_ascii_digit())
88        .collect::<String>()
89        .parse()
90        .unwrap_or(0);
91    Some((rank, n))
92}
93
94/// Does `version` satisfy a single constraint like `>=1.2`, `<2.0`, `==1.0.*`,
95/// `~=1.4`, `!=1.5`? Unknown operators / unparseable bounds → `false`.
96fn satisfies_one(version: &Version, constraint: &str) -> bool {
97    let c = constraint.trim();
98    if c.is_empty() {
99        return true;
100    }
101    let (op, rhs) = split_op(c);
102    // Wildcard handling for == / != (e.g. `==1.4.*`).
103    if (op == "==" || op == "!=") && rhs.ends_with(".*") {
104        let prefix = rhs.trim_end_matches(".*");
105        let Some(pv) = Version::parse(prefix) else {
106            return false;
107        };
108        let matches_prefix = version.release.len() >= pv.release.len()
109            && version.release[..pv.release.len()] == pv.release[..];
110        return if op == "==" {
111            matches_prefix
112        } else {
113            !matches_prefix
114        };
115    }
116    let Some(bound) = Version::parse(rhs) else {
117        return false;
118    };
119    let ord = version.cmp_to(&bound);
120    match op {
121        "==" => ord == Ordering::Equal,
122        "!=" => ord != Ordering::Equal,
123        "<" => ord == Ordering::Less,
124        "<=" => ord != Ordering::Greater,
125        ">" => ord == Ordering::Greater,
126        ">=" => ord != Ordering::Less,
127        "~=" => compatible_release(version, &bound),
128        _ => false,
129    }
130}
131
132/// `~=X.Y` means `>=X.Y, ==X.*`; `~=X.Y.Z` means `>=X.Y.Z, ==X.Y.*`.
133fn compatible_release(version: &Version, bound: &Version) -> bool {
134    if version.cmp_to(bound) == Ordering::Less {
135        return false;
136    }
137    if bound.release.len() < 2 {
138        return true; // `~=1` is invalid PEP 440; be permissive.
139    }
140    let keep = bound.release.len() - 1;
141    version.release.len() >= keep && version.release[..keep] == bound.release[..keep]
142}
143
144fn split_op(c: &str) -> (&str, &str) {
145    for op in ["==", "!=", "<=", ">=", "~=", "<", ">"] {
146        if let Some(rest) = c.strip_prefix(op) {
147            return (op, rest.trim());
148        }
149    }
150    // Bare version = exact match.
151    ("==", c)
152}
153
154/// Do two PEP 440 specifier sets have a **non-empty intersection** — i.e. does
155/// any version satisfy both `a` and `b`? Used to decide whether a declared
156/// *range* (e.g. `>=2.0`) permits a version that an advisory marks vulnerable
157/// (e.g. `<2.11.3`), without needing a concrete pin.
158///
159/// Sound finite sweep: every constraint's truth value only changes at one of
160/// the boundary versions named in `a`/`b`. We test each boundary, a point just
161/// above each boundary, and a point below all of them; if any candidate
162/// satisfies both specifier sets, they intersect.
163pub fn specs_intersect(a: &str, b: &str) -> bool {
164    let mut bounds: Vec<String> = Vec::new();
165    for spec in [a, b] {
166        for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
167            let (_, rhs) = split_op(part);
168            let rhs = rhs.trim_end_matches(".*").trim();
169            if Version::parse(rhs).is_some() {
170                bounds.push(rhs.to_string());
171            }
172        }
173    }
174    let mut candidates: Vec<String> = vec!["0".to_string()];
175    for bnd in &bounds {
176        candidates.push(bnd.clone());
177        candidates.push(format!("{bnd}.1")); // strictly just above this boundary
178        if let Some(inc) = incr_last(bnd) {
179            candidates.push(inc);
180        }
181    }
182    candidates
183        .iter()
184        .any(|c| matches_spec(c, a) && matches_spec(c, b))
185}
186
187/// Increment the last release component of a version string (`1.4` -> `1.5`).
188fn incr_last(v: &str) -> Option<String> {
189    let parsed = Version::parse(v)?;
190    let mut rel = parsed.release;
191    let last = rel.last_mut()?;
192    *last += 1;
193    Some(
194        rel.iter()
195            .map(|n| n.to_string())
196            .collect::<Vec<_>>()
197            .join("."),
198    )
199}
200
201/// Does `version` satisfy a comma-separated AND of constraints
202/// (e.g. `>=1.0,<2.0`)? An empty spec matches everything.
203pub fn matches_spec(version: &str, spec: &str) -> bool {
204    let Some(v) = Version::parse(version) else {
205        return false;
206    };
207    spec.split(',')
208        .map(str::trim)
209        .filter(|p| !p.is_empty())
210        .all(|p| satisfies_one(&v, p))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn orders_releases_and_prereleases() {
219        assert_eq!(
220            Version::parse("1.2.0")
221                .unwrap()
222                .cmp_to(&Version::parse("1.10.0").unwrap()),
223            Ordering::Less
224        );
225        // pre-release sorts before final
226        assert_eq!(
227            Version::parse("1.0rc1")
228                .unwrap()
229                .cmp_to(&Version::parse("1.0").unwrap()),
230            Ordering::Less
231        );
232        assert_eq!(
233            Version::parse("1.0a1")
234                .unwrap()
235                .cmp_to(&Version::parse("1.0b1").unwrap()),
236            Ordering::Less
237        );
238    }
239
240    #[test]
241    fn matches_ranges() {
242        assert!(matches_spec("2.4.1", "<2.11.3"));
243        assert!(!matches_spec("2.11.3", "<2.11.3"));
244        assert!(matches_spec("1.5", ">=1.0,<2.0"));
245        assert!(!matches_spec("2.0", ">=1.0,<2.0"));
246        assert!(matches_spec("1.4.7", "==1.4.*"));
247        assert!(!matches_spec("1.5.0", "==1.4.*"));
248        assert!(matches_spec("1.4.9", "~=1.4.2"));
249        assert!(!matches_spec("1.5.0", "~=1.4.2"));
250        assert!(matches_spec("3.1.2", ">=3.1.0"));
251    }
252
253    #[test]
254    fn unparseable_is_no_match() {
255        assert!(!matches_spec("not-a-version", "<2.0"));
256        assert!(!matches_spec("1.0", "≤2.0")); // unknown operator
257    }
258
259    #[test]
260    fn specifier_set_intersection() {
261        // A declared range that permits a vulnerable version intersects.
262        assert!(specs_intersect(">=2.0", "<2.11.3"));
263        assert!(specs_intersect(">=1.0,<3.0", ">=2.0,<2.5"));
264        assert!(specs_intersect("", "<2.0")); // empty (any) intersects anything satisfiable
265                                              // A declared range entirely above the vulnerable range does NOT intersect.
266        assert!(!specs_intersect(">=2.11.3", "<2.11.3"));
267        assert!(!specs_intersect(">=3.0", "<2.0"));
268        assert!(!specs_intersect(">=1.0,<2.0", ">=2.0"));
269        // Wildcards and compatible-release.
270        assert!(specs_intersect("~=1.4", "==1.4.7"));
271        assert!(!specs_intersect("~=1.4", "==2.0.0"));
272        assert!(specs_intersect(">=1.0", "==1.5.*"));
273    }
274}