Skip to main content

socket_patch_core/patch/sidecars/
mod.rs

1//! Per-ecosystem fixups for the integrity sidecars that package
2//! managers verify at build/install time.
3//!
4//! Patching a file inside a package directory leaves the ecosystem's
5//! own checksum metadata pointing at the pre-patch hash. The next
6//! `cargo build`, `pip check`, or `nuget restore` then either fails
7//! ("checksum changed") or flags the install as tampered. This
8//! module owns the post-apply rewrites that keep those sidecars
9//! consistent with what we just wrote to disk.
10//!
11//! Coverage in this revision:
12//!
13//! - **Cargo** ([`cargo::fixup`]): rewrite `.cargo-checksum.json` so
14//!   `cargo build` accepts the patched sources.
15//! - **NuGet** ([`nuget::fixup`]): delete `.nupkg.metadata` (we
16//!   cannot honestly recompute `contentHash` without the original
17//!   `.nupkg`; deletion is the "unknown" state vs. tampering-flag
18//!   for a stale hash). A signed-package `.nupkg.sha512` marker
19//!   surfaces an advisory ALONGSIDE the metadata deletion.
20//! - **PyPI / gem / Go**: advisory only — emit a structured
21//!   advisory so downstream tooling consequences are programmatic.
22//!   Full sidecar rewrites land in follow-ups.
23//!
24//! All ecosystems return a [`SidecarRecord`] via [`dispatch_fixup`].
25//! The record is the canonical JSON-envelope shape — see
26//! [`types`] for field documentation and stability guarantees.
27
28use 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/// Intermediate payload returned by per-ecosystem fixups. The
46/// wrapper [`dispatch_fixup`] adds `purl` + `ecosystem` to form a
47/// full [`SidecarRecord`]. Per-ecosystem code doesn't need to know
48/// PURL parsing.
49#[derive(Debug, Clone)]
50pub(crate) struct SidecarPayload {
51    pub files: Vec<SidecarFile>,
52    pub advisory: Option<SidecarAdvisory>,
53}
54
55/// Errors a sidecar fixup can return. Each is best-effort: a failing
56/// sidecar does NOT undo the patch (the patched bytes are already on
57/// disk). The boundary in `apply_package_patch` converts these to
58/// a [`SidecarRecord`] carrying `SidecarAdvisoryCode::SidecarFixupFailed`
59/// so consumers see a uniform shape.
60#[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
72/// Helper for advisory-only ecosystems (PyPI / gem / Go) — builds a
73/// payload with no touched files and a single structured advisory.
74pub(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/// Run the post-apply integrity fixup for the package's ecosystem.
90///
91/// Returns a fully-formed [`SidecarRecord`] (PURL + ecosystem +
92/// payload) when the ecosystem produced any output, `None` when
93/// the ecosystem has no sidecar contract at all (e.g. npm), or
94/// `Err(SidecarError)` when the fixup tried to do something and
95/// failed mid-flight. The caller is responsible for converting
96/// the error case into an `Error`-severity record.
97///
98/// `package_key` is the PURL. `pkg_path` is the package directory
99/// on disk. `patched` lists the patch-file keys that were actually
100/// written (same convention as `apply_package_patch.files_patched`).
101/// `files` is reserved for future use (currently unread).
102#[allow(unused_variables)] // `pkg_path` is feature-gated below
103pub 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        // PURL has no recognized prefix → dispatcher bails with None.
226        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    /// Regression: an empty `patched` list short-circuits to `None`
239    /// *before* the PURL is classified, even for an ecosystem that
240    /// would otherwise always emit an advisory (pypi). Guards the
241    /// `patched.is_empty()` early return at the top of `dispatch_fixup`
242    /// against being reordered below the advisory arms (which would
243    /// emit spurious advisories for no-op applies).
244    #[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    // ── Full-path dispatch coverage ──────────────────────────────────
257    // The tests above this point exercise advisory ecosystems and the
258    // None paths. The ones below drive `dispatch_fixup` end-to-end for
259    // the *file-touching* ecosystems (cargo rewrite, nuget delete) and
260    // the error boundary — the wiring between `dispatch_fixup` and the
261    // per-ecosystem fixups that the direct `cargo::fixup`/`nuget::fixup`
262    // unit tests don't cover.
263
264    /// Cargo PURL routes through `dispatch_fixup` to the checksum
265    /// rewriter and the resulting record denormalizes purl + ecosystem
266    /// and carries the rewritten-file entry.
267    #[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    /// Cargo crate with no `.cargo-checksum.json` → the sub-fixup
306    /// returns `None`, so `dispatch_fixup` produces no record (not an
307    /// empty-files record).
308    #[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    /// A malformed `.cargo-checksum.json` makes the sub-fixup error;
324    /// `dispatch_fixup` must propagate the `SidecarError` (the apply
325    /// boundary converts it to a `sidecar_fixup_failed` advisory) and
326    /// must NOT swallow it into `Ok(None)`.
327    #[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    /// NuGet PURL routes through `dispatch_fixup` to the metadata
346    /// neutralizer; the on-disk `.nupkg.metadata` is deleted and the
347    /// record records it as `Deleted`.
348    #[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    /// NuGet package with neither metadata nor signature → no record.
377    #[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    /// Go PURL routes through `dispatch_fixup` to the advisory-only
393    /// path and denormalizes the ecosystem name to `golang`.
394    #[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    /// When the `cargo` feature is disabled, a `pkg:cargo/` PURL is
415    /// unrecognized by `Ecosystem::from_purl` and `dispatch_fixup`
416    /// returns `None` rather than attempting (or panicking on) a fixup.
417    #[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}