Skip to main content

gem_audit/fixer/
lockfile_patcher.rs

1use thiserror::Error;
2
3use super::version_resolver::FixSuggestion;
4
5#[derive(Debug, Error)]
6pub enum PatchError {
7    #[error("gem '{name}' not found in lockfile")]
8    GemNotFound { name: String },
9}
10
11/// Patch a Gemfile.lock's raw text, replacing vulnerable gem versions with fixed versions.
12///
13/// Only modifies spec lines (indent=4) within GEM/GIT/PATH sections.
14/// Preserves platform suffixes, formatting, and all other content.
15///
16/// Returns the patched content and a list of gem names that were actually modified.
17pub fn patch_lockfile(content: &str, fixes: &[FixSuggestion]) -> (String, Vec<String>) {
18    if fixes.is_empty() {
19        return (content.to_string(), Vec::new());
20    }
21
22    let mut patched_names = Vec::new();
23    let mut output = String::with_capacity(content.len());
24
25    for line in content.lines() {
26        if let Some(patched_line) = try_patch_spec_line(line, fixes) {
27            output.push_str(&patched_line);
28            // Extract gem name from the patched line
29            if let Some(name) = extract_gem_name(line)
30                && !patched_names.contains(&name)
31            {
32                patched_names.push(name);
33            }
34        } else {
35            output.push_str(line);
36        }
37        output.push('\n');
38    }
39
40    // Preserve whether original content ended with newline
41    if !content.ends_with('\n') && output.ends_with('\n') {
42        output.pop();
43    }
44
45    (output, patched_names)
46}
47
48/// Try to patch a single spec line. Returns Some(patched_line) if matched, None otherwise.
49///
50/// Matches lines like:
51///   `    nokogiri (1.13.10)`
52///   `    nokogiri (1.13.10-x86_64-linux)`
53fn try_patch_spec_line(line: &str, fixes: &[FixSuggestion]) -> Option<String> {
54    // Spec lines have exactly 4 spaces indent
55    if !line.starts_with("    ") || line.starts_with("      ") {
56        return None;
57    }
58
59    let trimmed = line.trim_start();
60
61    // Must have format: `name (version)` or `name (version-platform)`
62    let paren_start = trimmed.find('(')?;
63    let paren_end = trimmed.find(')')?;
64    if paren_end <= paren_start {
65        return None;
66    }
67
68    let gem_name = trimmed[..paren_start].trim();
69    let version_platform = &trimmed[paren_start + 1..paren_end];
70
71    // Find if this gem has a fix
72    let fix = fixes.iter().find(|f| f.name == gem_name)?;
73
74    // Split version from platform suffix
75    let (old_version, platform) = split_version_platform(version_platform);
76
77    // Only patch if the old version matches (safety check)
78    if old_version != fix.current_version {
79        return None;
80    }
81
82    let new_version = fix.resolved_version.to_string();
83    let new_version_platform = match platform {
84        Some(p) => format!("{}-{}", new_version, p),
85        None => new_version,
86    };
87
88    // Reconstruct the line preserving original indentation
89    let indent = &line[..line.len() - line.trim_start().len()];
90    Some(format!("{}{} ({})", indent, gem_name, new_version_platform))
91}
92
93/// Split "1.13.10-x86_64-linux" into ("1.13.10", Some("x86_64-linux")).
94fn split_version_platform(input: &str) -> (&str, Option<&str>) {
95    // Known arch prefixes that start platform suffixes
96    let arch_prefixes = ["x86_64-", "x86-", "x64-", "arm64-", "aarch64-", "arm-"];
97    let keyword_platforms = ["java", "jruby", "universal-"];
98
99    for prefix in &arch_prefixes {
100        if let Some(pos) = input.find(prefix)
101            && pos > 0
102            && input.as_bytes()[pos - 1] == b'-'
103        {
104            return (&input[..pos - 1], Some(&input[pos..]));
105        }
106    }
107
108    for kw in &keyword_platforms {
109        if let Some(pos) = input.find(kw)
110            && pos > 0
111            && input.as_bytes()[pos - 1] == b'-'
112        {
113            return (&input[..pos - 1], Some(&input[pos..]));
114        }
115    }
116
117    (input, None)
118}
119
120fn extract_gem_name(line: &str) -> Option<String> {
121    let trimmed = line.trim_start();
122    let paren_start = trimmed.find('(')?;
123    Some(trimmed[..paren_start].trim().to_string())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::version::Version;
130
131    fn make_fix(name: &str, current: &str, resolved: &str) -> FixSuggestion {
132        FixSuggestion {
133            name: name.to_string(),
134            current_version: current.to_string(),
135            resolved_version: Version::parse(resolved).unwrap(),
136            advisory_ids: vec![],
137        }
138    }
139
140    #[test]
141    fn basic_version_replacement() {
142        let content = "\
143GEM
144  remote: https://rubygems.org/
145  specs:
146    nokogiri (1.13.10)
147      mini_portile2 (~> 2.8.0)
148
149PLATFORMS
150  ruby
151
152DEPENDENCIES
153  nokogiri
154";
155        let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
156        let (patched, names) = patch_lockfile(content, &fixes);
157        assert!(patched.contains("    nokogiri (1.14.0)"));
158        assert!(!patched.contains("1.13.10"));
159        assert_eq!(names, vec!["nokogiri"]);
160    }
161
162    #[test]
163    fn preserves_platform_suffix() {
164        let content = "\
165GEM
166  remote: https://rubygems.org/
167  specs:
168    nokogiri (1.13.10-x86_64-linux)
169";
170        let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
171        let (patched, _) = patch_lockfile(content, &fixes);
172        assert!(patched.contains("    nokogiri (1.14.0-x86_64-linux)"));
173    }
174
175    #[test]
176    fn preserves_musl_platform_suffix() {
177        let content = "\
178GEM
179  remote: https://rubygems.org/
180  specs:
181    nokogiri (1.19.1-aarch64-linux-musl)
182";
183        let fixes = vec![make_fix("nokogiri", "1.19.1", "1.19.2")];
184        let (patched, _) = patch_lockfile(content, &fixes);
185        assert!(patched.contains("    nokogiri (1.19.2-aarch64-linux-musl)"));
186    }
187
188    #[test]
189    fn multiple_platform_variants_all_updated() {
190        let content = "\
191GEM
192  remote: https://rubygems.org/
193  specs:
194    nokogiri (1.13.10)
195    nokogiri (1.13.10-x86_64-linux)
196    nokogiri (1.13.10-arm64-darwin)
197";
198        let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
199        let (patched, names) = patch_lockfile(content, &fixes);
200        assert!(patched.contains("    nokogiri (1.14.0)"));
201        assert!(patched.contains("    nokogiri (1.14.0-x86_64-linux)"));
202        assert!(patched.contains("    nokogiri (1.14.0-arm64-darwin)"));
203        // Name should appear only once in the list
204        assert_eq!(names.len(), 1);
205    }
206
207    #[test]
208    fn does_not_modify_dependency_lines() {
209        let content = "\
210GEM
211  remote: https://rubygems.org/
212  specs:
213    actionpack (3.2.10)
214      activemodel (= 3.2.10)
215";
216        let fixes = vec![make_fix("actionpack", "3.2.10", "5.2.8")];
217        let (patched, _) = patch_lockfile(content, &fixes);
218        assert!(patched.contains("    actionpack (5.2.8)"));
219        // Dependency line (6-space indent) should NOT be modified
220        assert!(patched.contains("      activemodel (= 3.2.10)"));
221    }
222
223    #[test]
224    fn does_not_modify_dependencies_section() {
225        let content = "\
226DEPENDENCIES
227  actionpack
228  nokogiri (~> 1.13)
229";
230        let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
231        let (patched, names) = patch_lockfile(content, &fixes);
232        assert_eq!(patched, content);
233        assert!(names.is_empty());
234    }
235
236    #[test]
237    fn empty_fixes_returns_original() {
238        let content = "GEM\n  specs:\n    test (1.0)\n";
239        let (patched, names) = patch_lockfile(content, &[]);
240        assert_eq!(patched, content);
241        assert!(names.is_empty());
242    }
243
244    #[test]
245    fn gem_not_in_lockfile_skipped() {
246        let content = "GEM\n  specs:\n    rack (2.0.0)\n";
247        let fixes = vec![make_fix("nonexistent", "1.0", "2.0")];
248        let (patched, names) = patch_lockfile(content, &fixes);
249        assert_eq!(patched, content);
250        assert!(names.is_empty());
251    }
252
253    #[test]
254    fn version_mismatch_not_patched() {
255        let content = "GEM\n  specs:\n    rack (2.0.0)\n";
256        let fixes = vec![make_fix("rack", "1.0.0", "2.0.0")]; // wrong current version
257        let (patched, names) = patch_lockfile(content, &fixes);
258        assert_eq!(patched, content);
259        assert!(names.is_empty());
260    }
261
262    #[test]
263    fn java_platform_preserved() {
264        let content = "GEM\n  specs:\n    jruby-openssl (9.2.14.0-java)\n";
265        let fixes = vec![make_fix("jruby-openssl", "9.2.14.0", "9.2.15.0")];
266        let (patched, _) = patch_lockfile(content, &fixes);
267        assert!(patched.contains("    jruby-openssl (9.2.15.0-java)"));
268    }
269
270    #[test]
271    fn split_version_platform_plain() {
272        let (v, p) = split_version_platform("1.13.10");
273        assert_eq!(v, "1.13.10");
274        assert_eq!(p, None);
275    }
276
277    #[test]
278    fn split_version_platform_linux() {
279        let (v, p) = split_version_platform("1.13.10-x86_64-linux");
280        assert_eq!(v, "1.13.10");
281        assert_eq!(p, Some("x86_64-linux"));
282    }
283
284    #[test]
285    fn split_version_platform_musl() {
286        let (v, p) = split_version_platform("1.19.1-aarch64-linux-musl");
287        assert_eq!(v, "1.19.1");
288        assert_eq!(p, Some("aarch64-linux-musl"));
289    }
290
291    #[test]
292    fn split_version_platform_java() {
293        let (v, p) = split_version_platform("9.2.14.0-java");
294        assert_eq!(v, "9.2.14.0");
295        assert_eq!(p, Some("java"));
296    }
297}