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")).
94///
95/// Delegates to the shared `lockfile::platform::split_version_platform`.
96fn split_version_platform(input: &str) -> (&str, Option<&str>) {
97    crate::lockfile::platform::split_version_platform(input)
98}
99
100fn extract_gem_name(line: &str) -> Option<String> {
101    let trimmed = line.trim_start();
102    let paren_start = trimmed.find('(')?;
103    Some(trimmed[..paren_start].trim().to_string())
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::version::Version;
110
111    fn make_fix(name: &str, current: &str, resolved: &str) -> FixSuggestion {
112        FixSuggestion {
113            name: name.to_string(),
114            current_version: current.to_string(),
115            resolved_version: Version::parse(resolved).unwrap(),
116            advisory_ids: vec![],
117        }
118    }
119
120    #[test]
121    fn basic_version_replacement() {
122        let content = "\
123GEM
124  remote: https://rubygems.org/
125  specs:
126    nokogiri (1.13.10)
127      mini_portile2 (~> 2.8.0)
128
129PLATFORMS
130  ruby
131
132DEPENDENCIES
133  nokogiri
134";
135        let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
136        let (patched, names) = patch_lockfile(content, &fixes);
137        assert!(patched.contains("    nokogiri (1.14.0)"));
138        assert!(!patched.contains("1.13.10"));
139        assert_eq!(names, vec!["nokogiri"]);
140    }
141
142    #[test]
143    fn preserves_platform_suffix() {
144        let content = "\
145GEM
146  remote: https://rubygems.org/
147  specs:
148    nokogiri (1.13.10-x86_64-linux)
149";
150        let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
151        let (patched, _) = patch_lockfile(content, &fixes);
152        assert!(patched.contains("    nokogiri (1.14.0-x86_64-linux)"));
153    }
154
155    #[test]
156    fn preserves_musl_platform_suffix() {
157        let content = "\
158GEM
159  remote: https://rubygems.org/
160  specs:
161    nokogiri (1.19.1-aarch64-linux-musl)
162";
163        let fixes = vec![make_fix("nokogiri", "1.19.1", "1.19.2")];
164        let (patched, _) = patch_lockfile(content, &fixes);
165        assert!(patched.contains("    nokogiri (1.19.2-aarch64-linux-musl)"));
166    }
167
168    #[test]
169    fn multiple_platform_variants_all_updated() {
170        let content = "\
171GEM
172  remote: https://rubygems.org/
173  specs:
174    nokogiri (1.13.10)
175    nokogiri (1.13.10-x86_64-linux)
176    nokogiri (1.13.10-arm64-darwin)
177";
178        let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
179        let (patched, names) = patch_lockfile(content, &fixes);
180        assert!(patched.contains("    nokogiri (1.14.0)"));
181        assert!(patched.contains("    nokogiri (1.14.0-x86_64-linux)"));
182        assert!(patched.contains("    nokogiri (1.14.0-arm64-darwin)"));
183        // Name should appear only once in the list
184        assert_eq!(names.len(), 1);
185    }
186
187    #[test]
188    fn does_not_modify_dependency_lines() {
189        let content = "\
190GEM
191  remote: https://rubygems.org/
192  specs:
193    actionpack (3.2.10)
194      activemodel (= 3.2.10)
195";
196        let fixes = vec![make_fix("actionpack", "3.2.10", "5.2.8")];
197        let (patched, _) = patch_lockfile(content, &fixes);
198        assert!(patched.contains("    actionpack (5.2.8)"));
199        // Dependency line (6-space indent) should NOT be modified
200        assert!(patched.contains("      activemodel (= 3.2.10)"));
201    }
202
203    #[test]
204    fn does_not_modify_dependencies_section() {
205        let content = "\
206DEPENDENCIES
207  actionpack
208  nokogiri (~> 1.13)
209";
210        let fixes = vec![make_fix("nokogiri", "1.13.10", "1.14.0")];
211        let (patched, names) = patch_lockfile(content, &fixes);
212        assert_eq!(patched, content);
213        assert!(names.is_empty());
214    }
215
216    #[test]
217    fn empty_fixes_returns_original() {
218        let content = "GEM\n  specs:\n    test (1.0)\n";
219        let (patched, names) = patch_lockfile(content, &[]);
220        assert_eq!(patched, content);
221        assert!(names.is_empty());
222    }
223
224    #[test]
225    fn gem_not_in_lockfile_skipped() {
226        let content = "GEM\n  specs:\n    rack (2.0.0)\n";
227        let fixes = vec![make_fix("nonexistent", "1.0", "2.0")];
228        let (patched, names) = patch_lockfile(content, &fixes);
229        assert_eq!(patched, content);
230        assert!(names.is_empty());
231    }
232
233    #[test]
234    fn version_mismatch_not_patched() {
235        let content = "GEM\n  specs:\n    rack (2.0.0)\n";
236        let fixes = vec![make_fix("rack", "1.0.0", "2.0.0")]; // wrong current version
237        let (patched, names) = patch_lockfile(content, &fixes);
238        assert_eq!(patched, content);
239        assert!(names.is_empty());
240    }
241
242    #[test]
243    fn java_platform_preserved() {
244        let content = "GEM\n  specs:\n    jruby-openssl (9.2.14.0-java)\n";
245        let fixes = vec![make_fix("jruby-openssl", "9.2.14.0", "9.2.15.0")];
246        let (patched, _) = patch_lockfile(content, &fixes);
247        assert!(patched.contains("    jruby-openssl (9.2.15.0-java)"));
248    }
249
250    #[test]
251    fn split_version_platform_plain() {
252        let (v, p) = split_version_platform("1.13.10");
253        assert_eq!(v, "1.13.10");
254        assert_eq!(p, None);
255    }
256
257    #[test]
258    fn split_version_platform_linux() {
259        let (v, p) = split_version_platform("1.13.10-x86_64-linux");
260        assert_eq!(v, "1.13.10");
261        assert_eq!(p, Some("x86_64-linux"));
262    }
263
264    #[test]
265    fn split_version_platform_musl() {
266        let (v, p) = split_version_platform("1.19.1-aarch64-linux-musl");
267        assert_eq!(v, "1.19.1");
268        assert_eq!(p, Some("aarch64-linux-musl"));
269    }
270
271    #[test]
272    fn split_version_platform_java() {
273        let (v, p) = split_version_platform("9.2.14.0-java");
274        assert_eq!(v, "9.2.14.0");
275        assert_eq!(p, Some("java"));
276    }
277}