socket_patch_core/patch/sidecars/
mod.rs1use std::collections::HashMap;
29use std::path::Path;
30
31use crate::crawlers::Ecosystem;
32use crate::manifest::schema::PatchFileInfo;
33
34#[cfg(feature = "cargo")]
35pub(crate) mod cargo;
36#[cfg(feature = "nuget")]
37pub(crate) mod nuget;
38pub mod types;
39
40pub use types::{
41 SidecarAdvisory, SidecarAdvisoryCode, SidecarFile, SidecarFileAction, SidecarRecord,
42 SidecarSeverity,
43};
44
45#[derive(Debug, Clone)]
50pub(crate) struct SidecarPayload {
51 pub files: Vec<SidecarFile>,
52 pub advisory: Option<SidecarAdvisory>,
53}
54
55#[derive(Debug, thiserror::Error)]
61pub enum SidecarError {
62 #[error("sidecar I/O error at {path}: {source}")]
63 Io {
64 path: String,
65 #[source]
66 source: std::io::Error,
67 },
68 #[error("malformed sidecar at {path}: {detail}")]
69 Malformed { path: String, detail: String },
70}
71
72pub(crate) fn advisory_only_payload(
75 code: SidecarAdvisoryCode,
76 severity: SidecarSeverity,
77 message: &str,
78) -> SidecarPayload {
79 SidecarPayload {
80 files: Vec::new(),
81 advisory: Some(SidecarAdvisory {
82 code,
83 severity,
84 message: message.to_string(),
85 }),
86 }
87}
88
89#[allow(unused_variables)] pub async fn dispatch_fixup(
104 package_key: &str,
105 pkg_path: &Path,
106 patched: &[String],
107 _files: &HashMap<String, PatchFileInfo>,
108) -> Result<Option<SidecarRecord>, SidecarError> {
109 if patched.is_empty() {
110 return Ok(None);
111 }
112
113 let ecosystem = match Ecosystem::from_purl(package_key) {
114 Some(eco) => eco,
115 None => return Ok(None),
116 };
117
118 let payload: Option<SidecarPayload> = match ecosystem {
119 #[cfg(feature = "cargo")]
120 Ecosystem::Cargo => cargo::fixup(pkg_path, patched).await?,
121 #[cfg(feature = "nuget")]
122 Ecosystem::Nuget => nuget::fixup(pkg_path).await?,
123 Ecosystem::Pypi => Some(advisory_only_payload(
124 SidecarAdvisoryCode::PypiRecordStale,
125 SidecarSeverity::Warning,
126 "PyPI: run `pip check` (or `uv pip check`) to verify \
127 .dist-info/RECORD consistency. `pip install --force-reinstall` \
128 or `uv pip install --reinstall` will revert these patches.",
129 )),
130 Ecosystem::Gem => Some(advisory_only_payload(
131 SidecarAdvisoryCode::GemBundleInstallReverts,
132 SidecarSeverity::Warning,
133 "Ruby gem: `bundle install --redownload` will revert these \
134 patches by reinstalling from the cached .gem.",
135 )),
136 #[cfg(feature = "golang")]
137 Ecosystem::Golang => Some(advisory_only_payload(
138 SidecarAdvisoryCode::GoModVerifyFails,
139 SidecarSeverity::Warning,
140 "Go: `go mod verify` will report a checksum mismatch against \
141 go.sum. `go build` works as long as the module cache stays warm.",
142 )),
143 _ => None,
144 };
145
146 Ok(payload.map(|p| SidecarRecord {
147 purl: package_key.to_string(),
148 ecosystem: ecosystem.cli_name().to_string(),
149 files: p.files,
150 advisory: p.advisory,
151 }))
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn empty_files() -> HashMap<String, PatchFileInfo> {
159 HashMap::new()
160 }
161
162 #[tokio::test]
163 async fn empty_patched_returns_none() {
164 let d = tempfile::tempdir().unwrap();
165 let out = dispatch_fixup("pkg:npm/anything@1.0.0", d.path(), &[], &empty_files())
166 .await
167 .unwrap();
168 assert!(out.is_none());
169 }
170
171 #[tokio::test]
172 async fn npm_has_no_sidecar() {
173 let d = tempfile::tempdir().unwrap();
174 let out = dispatch_fixup(
175 "pkg:npm/anything@1.0.0",
176 d.path(),
177 &["package/x.js".to_string()],
178 &empty_files(),
179 )
180 .await
181 .unwrap();
182 assert!(out.is_none());
183 }
184
185 #[tokio::test]
186 async fn pypi_returns_structured_advisory() {
187 let d = tempfile::tempdir().unwrap();
188 let out = dispatch_fixup(
189 "pkg:pypi/requests@2.28.0",
190 d.path(),
191 &["package/foo.py".to_string()],
192 &empty_files(),
193 )
194 .await
195 .unwrap();
196 let record = out.expect("pypi should return a record");
197 assert_eq!(record.ecosystem, "pypi");
198 assert_eq!(record.purl, "pkg:pypi/requests@2.28.0");
199 assert!(record.files.is_empty());
200 let advisory = record.advisory.expect("pypi must carry an advisory");
201 assert_eq!(advisory.code, SidecarAdvisoryCode::PypiRecordStale);
202 assert_eq!(advisory.severity, SidecarSeverity::Warning);
203 assert!(advisory.message.contains("pip"));
204 }
205
206 #[tokio::test]
207 async fn gem_returns_structured_advisory() {
208 let d = tempfile::tempdir().unwrap();
209 let out = dispatch_fixup(
210 "pkg:gem/rails@7.1.0",
211 d.path(),
212 &["lib/rails.rb".to_string()],
213 &empty_files(),
214 )
215 .await
216 .unwrap();
217 let record = out.expect("gem should return a record");
218 assert_eq!(record.ecosystem, "gem");
219 let advisory = record.advisory.expect("gem must carry an advisory");
220 assert_eq!(advisory.code, SidecarAdvisoryCode::GemBundleInstallReverts);
221 }
222
223 #[tokio::test]
224 async fn unknown_ecosystem_returns_none() {
225 let d = tempfile::tempdir().unwrap();
227 let out = dispatch_fixup(
228 "pkg:weirdo/x@1",
229 d.path(),
230 &["x".to_string()],
231 &empty_files(),
232 )
233 .await
234 .unwrap();
235 assert!(out.is_none());
236 }
237
238 #[tokio::test]
245 async fn empty_patched_short_circuits_before_advisory() {
246 let d = tempfile::tempdir().unwrap();
247 let out = dispatch_fixup("pkg:pypi/requests@2.28.0", d.path(), &[], &empty_files())
248 .await
249 .unwrap();
250 assert!(
251 out.is_none(),
252 "no files patched ⇒ no sidecar record, even for advisory ecosystems"
253 );
254 }
255
256 #[cfg(feature = "cargo")]
268 #[tokio::test]
269 async fn cargo_dispatch_rewrites_checksum_and_builds_record() {
270 let d = tempfile::tempdir().unwrap();
271 let pkg = d.path();
272 tokio::fs::create_dir_all(pkg.join("src")).await.unwrap();
273 tokio::fs::write(pkg.join("src/lib.rs"), b"patched lib")
274 .await
275 .unwrap();
276 let starting = serde_json::json!({
277 "files": { "src/lib.rs": "00".repeat(32) },
278 "package": "x",
279 });
280 tokio::fs::write(
281 pkg.join(".cargo-checksum.json"),
282 serde_json::to_string_pretty(&starting).unwrap(),
283 )
284 .await
285 .unwrap();
286
287 let out = dispatch_fixup(
288 "pkg:cargo/mycrate@1.0.0",
289 pkg,
290 &["src/lib.rs".to_string()],
291 &empty_files(),
292 )
293 .await
294 .unwrap();
295
296 let record = out.expect("cargo dispatch must produce a record");
297 assert_eq!(record.ecosystem, "cargo");
298 assert_eq!(record.purl, "pkg:cargo/mycrate@1.0.0");
299 assert_eq!(record.files.len(), 1);
300 assert_eq!(record.files[0].path, ".cargo-checksum.json");
301 assert_eq!(record.files[0].action, SidecarFileAction::Rewritten);
302 assert!(record.advisory.is_none());
303 }
304
305 #[cfg(feature = "cargo")]
309 #[tokio::test]
310 async fn cargo_dispatch_without_checksum_returns_none() {
311 let d = tempfile::tempdir().unwrap();
312 let out = dispatch_fixup(
313 "pkg:cargo/mycrate@1.0.0",
314 d.path(),
315 &["src/lib.rs".to_string()],
316 &empty_files(),
317 )
318 .await
319 .unwrap();
320 assert!(out.is_none());
321 }
322
323 #[cfg(feature = "cargo")]
328 #[tokio::test]
329 async fn cargo_dispatch_propagates_malformed_error() {
330 let d = tempfile::tempdir().unwrap();
331 tokio::fs::write(d.path().join(".cargo-checksum.json"), b"not json")
332 .await
333 .unwrap();
334 let err = dispatch_fixup(
335 "pkg:cargo/mycrate@1.0.0",
336 d.path(),
337 &["src/lib.rs".to_string()],
338 &empty_files(),
339 )
340 .await
341 .unwrap_err();
342 assert!(matches!(err, SidecarError::Malformed { .. }));
343 }
344
345 #[cfg(feature = "nuget")]
349 #[tokio::test]
350 async fn nuget_dispatch_deletes_metadata_and_builds_record() {
351 let d = tempfile::tempdir().unwrap();
352 tokio::fs::write(d.path().join(".nupkg.metadata"), b"{}")
353 .await
354 .unwrap();
355
356 let out = dispatch_fixup(
357 "pkg:nuget/Newtonsoft.Json@13.0.3",
358 d.path(),
359 &["lib/x.dll".to_string()],
360 &empty_files(),
361 )
362 .await
363 .unwrap();
364
365 let record = out.expect("nuget dispatch must produce a record");
366 assert_eq!(record.ecosystem, "nuget");
367 assert_eq!(record.files.len(), 1);
368 assert_eq!(record.files[0].path, ".nupkg.metadata");
369 assert_eq!(record.files[0].action, SidecarFileAction::Deleted);
370 assert!(record.advisory.is_none());
371 assert!(tokio::fs::metadata(d.path().join(".nupkg.metadata"))
372 .await
373 .is_err());
374 }
375
376 #[cfg(feature = "nuget")]
378 #[tokio::test]
379 async fn nuget_dispatch_nothing_to_do_returns_none() {
380 let d = tempfile::tempdir().unwrap();
381 let out = dispatch_fixup(
382 "pkg:nuget/Newtonsoft.Json@13.0.3",
383 d.path(),
384 &["lib/x.dll".to_string()],
385 &empty_files(),
386 )
387 .await
388 .unwrap();
389 assert!(out.is_none());
390 }
391
392 #[cfg(feature = "golang")]
395 #[tokio::test]
396 async fn golang_dispatch_returns_structured_advisory() {
397 let d = tempfile::tempdir().unwrap();
398 let out = dispatch_fixup(
399 "pkg:golang/github.com/gin-gonic/gin@v1.9.1",
400 d.path(),
401 &["gin.go".to_string()],
402 &empty_files(),
403 )
404 .await
405 .unwrap();
406 let record = out.expect("golang should return a record");
407 assert_eq!(record.ecosystem, "golang");
408 assert!(record.files.is_empty());
409 let advisory = record.advisory.expect("golang must carry an advisory");
410 assert_eq!(advisory.code, SidecarAdvisoryCode::GoModVerifyFails);
411 assert_eq!(advisory.severity, SidecarSeverity::Warning);
412 }
413
414 #[cfg(not(feature = "cargo"))]
418 #[tokio::test]
419 async fn cargo_purl_without_feature_returns_none() {
420 let d = tempfile::tempdir().unwrap();
421 let out = dispatch_fixup(
422 "pkg:cargo/mycrate@1.0.0",
423 d.path(),
424 &["src/lib.rs".to_string()],
425 &empty_files(),
426 )
427 .await
428 .unwrap();
429 assert!(out.is_none());
430 }
431}