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!(
221 advisory.code,
222 SidecarAdvisoryCode::GemBundleInstallReverts
223 );
224 }
225
226 #[tokio::test]
227 async fn unknown_ecosystem_returns_none() {
228 let d = tempfile::tempdir().unwrap();
230 let out = dispatch_fixup(
231 "pkg:weirdo/x@1",
232 d.path(),
233 &["x".to_string()],
234 &empty_files(),
235 )
236 .await
237 .unwrap();
238 assert!(out.is_none());
239 }
240}