use std::collections::HashMap;
use std::path::Path;
use crate::crawlers::Ecosystem;
use crate::manifest::schema::PatchFileInfo;
#[cfg(feature = "cargo")]
pub(crate) mod cargo;
#[cfg(feature = "nuget")]
pub(crate) mod nuget;
pub mod types;
pub use types::{
SidecarAdvisory, SidecarAdvisoryCode, SidecarFile, SidecarFileAction, SidecarRecord,
SidecarSeverity,
};
#[derive(Debug, Clone)]
pub(crate) struct SidecarPayload {
pub files: Vec<SidecarFile>,
pub advisory: Option<SidecarAdvisory>,
}
#[derive(Debug, thiserror::Error)]
pub enum SidecarError {
#[error("sidecar I/O error at {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("malformed sidecar at {path}: {detail}")]
Malformed { path: String, detail: String },
}
pub(crate) fn advisory_only_payload(
code: SidecarAdvisoryCode,
severity: SidecarSeverity,
message: &str,
) -> SidecarPayload {
SidecarPayload {
files: Vec::new(),
advisory: Some(SidecarAdvisory {
code,
severity,
message: message.to_string(),
}),
}
}
#[allow(unused_variables)] pub async fn dispatch_fixup(
package_key: &str,
pkg_path: &Path,
patched: &[String],
_files: &HashMap<String, PatchFileInfo>,
) -> Result<Option<SidecarRecord>, SidecarError> {
if patched.is_empty() {
return Ok(None);
}
let ecosystem = match Ecosystem::from_purl(package_key) {
Some(eco) => eco,
None => return Ok(None),
};
let payload: Option<SidecarPayload> = match ecosystem {
#[cfg(feature = "cargo")]
Ecosystem::Cargo => cargo::fixup(pkg_path, patched).await?,
#[cfg(feature = "nuget")]
Ecosystem::Nuget => nuget::fixup(pkg_path).await?,
Ecosystem::Pypi => Some(advisory_only_payload(
SidecarAdvisoryCode::PypiRecordStale,
SidecarSeverity::Warning,
"PyPI: run `pip check` (or `uv pip check`) to verify \
.dist-info/RECORD consistency. `pip install --force-reinstall` \
or `uv pip install --reinstall` will revert these patches.",
)),
Ecosystem::Gem => Some(advisory_only_payload(
SidecarAdvisoryCode::GemBundleInstallReverts,
SidecarSeverity::Warning,
"Ruby gem: `bundle install --redownload` will revert these \
patches by reinstalling from the cached .gem.",
)),
#[cfg(feature = "golang")]
Ecosystem::Golang => Some(advisory_only_payload(
SidecarAdvisoryCode::GoModVerifyFails,
SidecarSeverity::Warning,
"Go: `go mod verify` will report a checksum mismatch against \
go.sum. `go build` works as long as the module cache stays warm.",
)),
_ => None,
};
Ok(payload.map(|p| SidecarRecord {
purl: package_key.to_string(),
ecosystem: ecosystem.cli_name().to_string(),
files: p.files,
advisory: p.advisory,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_files() -> HashMap<String, PatchFileInfo> {
HashMap::new()
}
#[tokio::test]
async fn empty_patched_returns_none() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup("pkg:npm/anything@1.0.0", d.path(), &[], &empty_files())
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn npm_has_no_sidecar() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup(
"pkg:npm/anything@1.0.0",
d.path(),
&["package/x.js".to_string()],
&empty_files(),
)
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn pypi_returns_structured_advisory() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup(
"pkg:pypi/requests@2.28.0",
d.path(),
&["package/foo.py".to_string()],
&empty_files(),
)
.await
.unwrap();
let record = out.expect("pypi should return a record");
assert_eq!(record.ecosystem, "pypi");
assert_eq!(record.purl, "pkg:pypi/requests@2.28.0");
assert!(record.files.is_empty());
let advisory = record.advisory.expect("pypi must carry an advisory");
assert_eq!(advisory.code, SidecarAdvisoryCode::PypiRecordStale);
assert_eq!(advisory.severity, SidecarSeverity::Warning);
assert!(advisory.message.contains("pip"));
}
#[tokio::test]
async fn gem_returns_structured_advisory() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup(
"pkg:gem/rails@7.1.0",
d.path(),
&["lib/rails.rb".to_string()],
&empty_files(),
)
.await
.unwrap();
let record = out.expect("gem should return a record");
assert_eq!(record.ecosystem, "gem");
let advisory = record.advisory.expect("gem must carry an advisory");
assert_eq!(advisory.code, SidecarAdvisoryCode::GemBundleInstallReverts);
}
#[tokio::test]
async fn unknown_ecosystem_returns_none() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup(
"pkg:weirdo/x@1",
d.path(),
&["x".to_string()],
&empty_files(),
)
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn empty_patched_short_circuits_before_advisory() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup("pkg:pypi/requests@2.28.0", d.path(), &[], &empty_files())
.await
.unwrap();
assert!(
out.is_none(),
"no files patched ⇒ no sidecar record, even for advisory ecosystems"
);
}
#[cfg(feature = "cargo")]
#[tokio::test]
async fn cargo_dispatch_rewrites_checksum_and_builds_record() {
let d = tempfile::tempdir().unwrap();
let pkg = d.path();
tokio::fs::create_dir_all(pkg.join("src")).await.unwrap();
tokio::fs::write(pkg.join("src/lib.rs"), b"patched lib")
.await
.unwrap();
let starting = serde_json::json!({
"files": { "src/lib.rs": "00".repeat(32) },
"package": "x",
});
tokio::fs::write(
pkg.join(".cargo-checksum.json"),
serde_json::to_string_pretty(&starting).unwrap(),
)
.await
.unwrap();
let out = dispatch_fixup(
"pkg:cargo/mycrate@1.0.0",
pkg,
&["src/lib.rs".to_string()],
&empty_files(),
)
.await
.unwrap();
let record = out.expect("cargo dispatch must produce a record");
assert_eq!(record.ecosystem, "cargo");
assert_eq!(record.purl, "pkg:cargo/mycrate@1.0.0");
assert_eq!(record.files.len(), 1);
assert_eq!(record.files[0].path, ".cargo-checksum.json");
assert_eq!(record.files[0].action, SidecarFileAction::Rewritten);
assert!(record.advisory.is_none());
}
#[cfg(feature = "cargo")]
#[tokio::test]
async fn cargo_dispatch_without_checksum_returns_none() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup(
"pkg:cargo/mycrate@1.0.0",
d.path(),
&["src/lib.rs".to_string()],
&empty_files(),
)
.await
.unwrap();
assert!(out.is_none());
}
#[cfg(feature = "cargo")]
#[tokio::test]
async fn cargo_dispatch_propagates_malformed_error() {
let d = tempfile::tempdir().unwrap();
tokio::fs::write(d.path().join(".cargo-checksum.json"), b"not json")
.await
.unwrap();
let err = dispatch_fixup(
"pkg:cargo/mycrate@1.0.0",
d.path(),
&["src/lib.rs".to_string()],
&empty_files(),
)
.await
.unwrap_err();
assert!(matches!(err, SidecarError::Malformed { .. }));
}
#[cfg(feature = "nuget")]
#[tokio::test]
async fn nuget_dispatch_deletes_metadata_and_builds_record() {
let d = tempfile::tempdir().unwrap();
tokio::fs::write(d.path().join(".nupkg.metadata"), b"{}")
.await
.unwrap();
let out = dispatch_fixup(
"pkg:nuget/Newtonsoft.Json@13.0.3",
d.path(),
&["lib/x.dll".to_string()],
&empty_files(),
)
.await
.unwrap();
let record = out.expect("nuget dispatch must produce a record");
assert_eq!(record.ecosystem, "nuget");
assert_eq!(record.files.len(), 1);
assert_eq!(record.files[0].path, ".nupkg.metadata");
assert_eq!(record.files[0].action, SidecarFileAction::Deleted);
assert!(record.advisory.is_none());
assert!(tokio::fs::metadata(d.path().join(".nupkg.metadata"))
.await
.is_err());
}
#[cfg(feature = "nuget")]
#[tokio::test]
async fn nuget_dispatch_nothing_to_do_returns_none() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup(
"pkg:nuget/Newtonsoft.Json@13.0.3",
d.path(),
&["lib/x.dll".to_string()],
&empty_files(),
)
.await
.unwrap();
assert!(out.is_none());
}
#[cfg(feature = "golang")]
#[tokio::test]
async fn golang_dispatch_returns_structured_advisory() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup(
"pkg:golang/github.com/gin-gonic/gin@v1.9.1",
d.path(),
&["gin.go".to_string()],
&empty_files(),
)
.await
.unwrap();
let record = out.expect("golang should return a record");
assert_eq!(record.ecosystem, "golang");
assert!(record.files.is_empty());
let advisory = record.advisory.expect("golang must carry an advisory");
assert_eq!(advisory.code, SidecarAdvisoryCode::GoModVerifyFails);
assert_eq!(advisory.severity, SidecarSeverity::Warning);
}
#[cfg(not(feature = "cargo"))]
#[tokio::test]
async fn cargo_purl_without_feature_returns_none() {
let d = tempfile::tempdir().unwrap();
let out = dispatch_fixup(
"pkg:cargo/mycrate@1.0.0",
d.path(),
&["src/lib.rs".to_string()],
&empty_files(),
)
.await
.unwrap();
assert!(out.is_none());
}
}