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!(
221            advisory.code,
222            SidecarAdvisoryCode::GemBundleInstallReverts
223        );
224    }
225
226    #[tokio::test]
227    async fn unknown_ecosystem_returns_none() {
228        // PURL has no recognized prefix → dispatcher bails with None.
229        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}