Skip to main content

gem_audit/fixer/
version_resolver.rs

1use crate::scanner::Remediation;
2use crate::version::Version;
3
4/// A fix suggestion for a single gem: the resolved minimum safe version.
5#[derive(Debug)]
6pub struct FixSuggestion {
7    pub name: String,
8    pub current_version: String,
9    pub resolved_version: Version,
10    pub advisory_ids: Vec<String>,
11}
12
13/// Result of attempting to resolve a fix for a gem.
14#[derive(Debug)]
15pub enum FixResult {
16    /// A safe version was found.
17    Fixed(FixSuggestion),
18    /// No safe version could be computed.
19    Unresolvable {
20        name: String,
21        current_version: String,
22        advisory_ids: Vec<String>,
23    },
24}
25
26impl FixResult {
27    pub fn name(&self) -> &str {
28        match self {
29            FixResult::Fixed(f) => &f.name,
30            FixResult::Unresolvable { name, .. } => name,
31        }
32    }
33}
34
35/// Resolve minimum safe versions for all remediations.
36///
37/// For each gem, collects all advisory `patched_versions` and finds the smallest
38/// version that satisfies ALL advisories simultaneously.
39///
40/// Each advisory's `patched_versions` are OR'd (any one suffices for that advisory).
41/// Multiple advisories for the same gem are AND'd (must satisfy all).
42pub fn resolve_fixes(remediations: &[Remediation]) -> Vec<FixResult> {
43    remediations.iter().map(resolve_single).collect()
44}
45
46fn resolve_single(remediation: &Remediation) -> FixResult {
47    let advisory_ids: Vec<String> = remediation
48        .advisories
49        .iter()
50        .flat_map(|a| a.identifiers())
51        .collect();
52
53    // Collect candidate minimum versions from all advisories' patched_versions.
54    // Each advisory's patched_versions are OR'd, so we take the minimum from each.
55    let mut candidates: Vec<Version> = Vec::new();
56    for adv in &remediation.advisories {
57        for req in &adv.patched_versions {
58            if let Some(v) = req.minimum_version() {
59                candidates.push(v);
60            }
61        }
62    }
63
64    if candidates.is_empty() {
65        return FixResult::Unresolvable {
66            name: remediation.name.clone(),
67            current_version: remediation.version.clone(),
68            advisory_ids,
69        };
70    }
71
72    // Sort candidates ascending — try smallest first
73    candidates.sort();
74    candidates.dedup();
75
76    // Find the smallest candidate that satisfies ALL advisories
77    for candidate in &candidates {
78        if all_advisories_patched(remediation, candidate) {
79            return FixResult::Fixed(FixSuggestion {
80                name: remediation.name.clone(),
81                current_version: remediation.version.clone(),
82                resolved_version: candidate.clone(),
83                advisory_ids,
84            });
85        }
86    }
87
88    // None of the exact candidates worked — try the largest candidate, which should
89    // satisfy the most constraints
90    FixResult::Unresolvable {
91        name: remediation.name.clone(),
92        current_version: remediation.version.clone(),
93        advisory_ids,
94    }
95}
96
97/// Check if a version is patched against ALL advisories for a gem.
98fn all_advisories_patched(remediation: &Remediation, version: &Version) -> bool {
99    remediation
100        .advisories
101        .iter()
102        .all(|adv| adv.patched(version))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::advisory::Advisory;
109    use std::path::Path;
110
111    fn make_advisory(yaml: &str) -> Advisory {
112        Advisory::from_yaml(yaml, Path::new("test.yml")).unwrap()
113    }
114
115    fn make_remediation(name: &str, version: &str, advisories: Vec<Advisory>) -> Remediation {
116        Remediation {
117            name: name.to_string(),
118            version: version.to_string(),
119            advisories,
120        }
121    }
122
123    #[test]
124    fn single_advisory_gte() {
125        let adv =
126            make_advisory("---\ngem: test\ncve: 2020-0001\npatched_versions:\n  - \">= 1.0.0\"\n");
127        let rem = make_remediation("test", "0.5.0", vec![adv]);
128        let results = resolve_fixes(&[rem]);
129        assert_eq!(results.len(), 1);
130        match &results[0] {
131            FixResult::Fixed(f) => {
132                assert_eq!(f.resolved_version, Version::parse("1.0.0").unwrap());
133            }
134            _ => panic!("expected Fixed"),
135        }
136    }
137
138    #[test]
139    fn single_advisory_pessimistic() {
140        let adv =
141            make_advisory("---\ngem: test\ncve: 2020-0001\npatched_versions:\n  - \"~> 1.18.7\"\n");
142        let rem = make_remediation("test", "1.18.0", vec![adv]);
143        let results = resolve_fixes(&[rem]);
144        match &results[0] {
145            FixResult::Fixed(f) => {
146                assert_eq!(f.resolved_version, Version::parse("1.18.7").unwrap());
147            }
148            _ => panic!("expected Fixed"),
149        }
150    }
151
152    #[test]
153    fn single_advisory_gt() {
154        let adv =
155            make_advisory("---\ngem: test\ncve: 2020-0001\npatched_versions:\n  - \"> 1.0.0\"\n");
156        let rem = make_remediation("test", "0.5.0", vec![adv]);
157        let results = resolve_fixes(&[rem]);
158        match &results[0] {
159            FixResult::Fixed(f) => {
160                assert_eq!(f.resolved_version, Version::parse("1.0.1").unwrap());
161            }
162            _ => panic!("expected Fixed"),
163        }
164    }
165
166    #[test]
167    fn multiple_patched_versions_or_picks_smallest() {
168        // patched_versions: ["~> 1.18.7", ">= 1.19.1"] — OR, pick smallest
169        let adv = make_advisory(
170            "---\ngem: test\ncve: 2020-0001\npatched_versions:\n  - \"~> 1.18.7\"\n  - \">= 1.19.1\"\n",
171        );
172        let rem = make_remediation("test", "1.18.0", vec![adv]);
173        let results = resolve_fixes(&[rem]);
174        match &results[0] {
175            FixResult::Fixed(f) => {
176                assert_eq!(f.resolved_version, Version::parse("1.18.7").unwrap());
177            }
178            _ => panic!("expected Fixed"),
179        }
180    }
181
182    #[test]
183    fn two_advisories_and_takes_intersection() {
184        // advisory1: patched >= 1.5
185        // advisory2: patched >= 2.0
186        // AND → need >= 2.0
187        let adv1 =
188            make_advisory("---\ngem: test\ncve: 2020-0001\npatched_versions:\n  - \">= 1.5\"\n");
189        let adv2 =
190            make_advisory("---\ngem: test\ncve: 2020-0002\npatched_versions:\n  - \">= 2.0\"\n");
191        let rem = make_remediation("test", "1.0.0", vec![adv1, adv2]);
192        let results = resolve_fixes(&[rem]);
193        match &results[0] {
194            FixResult::Fixed(f) => {
195                assert_eq!(f.resolved_version, Version::parse("2.0").unwrap());
196            }
197            _ => panic!("expected Fixed"),
198        }
199    }
200
201    #[test]
202    fn no_patched_versions_is_unresolvable() {
203        let adv = make_advisory("---\ngem: test\ncve: 2020-0001\npatched_versions: []\n");
204        let rem = make_remediation("test", "1.0.0", vec![adv]);
205        let results = resolve_fixes(&[rem]);
206        assert!(matches!(&results[0], FixResult::Unresolvable { .. }));
207    }
208
209    #[test]
210    fn empty_remediations() {
211        let results = resolve_fixes(&[]);
212        assert!(results.is_empty());
213    }
214
215    #[test]
216    fn complex_multi_advisory_multi_patched() {
217        // advisory1: patched by ~> 4.2.5 OR >= 5.0.0
218        // advisory2: patched by >= 5.0.1
219        // The smallest ~> 4.2.5 is 4.2.5 but that doesn't satisfy advisory2 (>= 5.0.1)
220        // The smallest >= 5.0.0 is 5.0.0 but that doesn't satisfy advisory2 (>= 5.0.1)
221        // So answer is 5.0.1
222        let adv1 = make_advisory(
223            "---\ngem: test\ncve: 2020-0001\npatched_versions:\n  - \"~> 4.2.5\"\n  - \">= 5.0.0\"\n",
224        );
225        let adv2 =
226            make_advisory("---\ngem: test\ncve: 2020-0002\npatched_versions:\n  - \">= 5.0.1\"\n");
227        let rem = make_remediation("test", "4.2.0", vec![adv1, adv2]);
228        let results = resolve_fixes(&[rem]);
229        match &results[0] {
230            FixResult::Fixed(f) => {
231                assert_eq!(f.resolved_version, Version::parse("5.0.1").unwrap());
232            }
233            _ => panic!("expected Fixed"),
234        }
235    }
236}