gem_audit/fixer/
lockfile_patcher.rs1use 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
11pub 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 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 if !content.ends_with('\n') && output.ends_with('\n') {
42 output.pop();
43 }
44
45 (output, patched_names)
46}
47
48fn try_patch_spec_line(line: &str, fixes: &[FixSuggestion]) -> Option<String> {
54 if !line.starts_with(" ") || line.starts_with(" ") {
56 return None;
57 }
58
59 let trimmed = line.trim_start();
60
61 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 let fix = fixes.iter().find(|f| f.name == gem_name)?;
73
74 let (old_version, platform) = split_version_platform(version_platform);
76
77 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 let indent = &line[..line.len() - line.trim_start().len()];
90 Some(format!("{}{} ({})", indent, gem_name, new_version_platform))
91}
92
93fn 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 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 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")]; 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}