use std::path::Path;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::patch::apply::normalize_file_path;
use super::{SidecarError, SidecarFile, SidecarFileAction, SidecarPayload};
const CHECKSUM_FILE: &str = ".cargo-checksum.json";
pub(crate) async fn fixup(
pkg_path: &Path,
patched: &[String],
) -> Result<Option<SidecarPayload>, SidecarError> {
let checksum_path = pkg_path.join(CHECKSUM_FILE);
let raw = match tokio::fs::read_to_string(&checksum_path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(None);
}
Err(source) => {
return Err(SidecarError::Io {
path: checksum_path.display().to_string(),
source,
});
}
};
let mut json: Value =
serde_json::from_str(&raw).map_err(|e| SidecarError::Malformed {
path: checksum_path.display().to_string(),
detail: e.to_string(),
})?;
let files = json
.get_mut("files")
.and_then(Value::as_object_mut)
.ok_or_else(|| SidecarError::Malformed {
path: checksum_path.display().to_string(),
detail: "missing or non-object `files` field".to_string(),
})?;
update_entries(files, pkg_path, patched).await?;
let mut out = serde_json::to_vec_pretty(&json)
.expect("serializing a Value just deserialized from valid JSON must succeed");
out.push(b'\n');
tokio::fs::write(&checksum_path, out).await.map_err(|source| {
SidecarError::Io {
path: checksum_path.display().to_string(),
source,
}
})?;
Ok(Some(SidecarPayload {
files: vec![SidecarFile {
path: CHECKSUM_FILE.to_string(),
action: SidecarFileAction::Rewritten,
}],
advisory: None,
}))
}
async fn update_entries(
files: &mut Map<String, Value>,
pkg_path: &Path,
patched: &[String],
) -> Result<(), SidecarError> {
for file_name in patched {
let normalized = normalize_file_path(file_name).to_string();
let on_disk = pkg_path.join(&normalized);
let hash = sha256_file(&on_disk).await.map_err(|source| SidecarError::Io {
path: on_disk.display().to_string(),
source,
})?;
files.insert(normalized, Value::String(hash));
}
Ok(())
}
async fn sha256_file(path: &Path) -> std::io::Result<String> {
let bytes = tokio::fs::read(path).await?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(format!("{:x}", hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
fn expected_sha256(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
format!("{:x}", h.finalize())
}
#[tokio::test]
async fn rewrites_only_patched_files() {
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();
tokio::fs::write(pkg.join("Cargo.toml"), b"unchanged").await.unwrap();
let starting = serde_json::json!({
"files": {
"src/lib.rs": "00".repeat(32),
"Cargo.toml": "11".repeat(32),
},
"package": "stale-package-hash",
});
tokio::fs::write(
pkg.join(CHECKSUM_FILE),
serde_json::to_string_pretty(&starting).unwrap(),
)
.await
.unwrap();
let out = fixup(pkg, &["src/lib.rs".to_string()]).await.unwrap();
let payload = out.expect("checksum file existed, fixup should return a payload");
assert_eq!(payload.files.len(), 1);
assert_eq!(payload.files[0].path, CHECKSUM_FILE);
assert_eq!(payload.files[0].action, SidecarFileAction::Rewritten);
assert!(payload.advisory.is_none());
let post: serde_json::Value = serde_json::from_str(
&tokio::fs::read_to_string(pkg.join(CHECKSUM_FILE)).await.unwrap(),
)
.unwrap();
let files = post["files"].as_object().unwrap();
assert_eq!(
files["src/lib.rs"].as_str().unwrap(),
expected_sha256(b"patched lib")
);
assert_eq!(files["Cargo.toml"].as_str().unwrap(), "11".repeat(32));
assert_eq!(post["package"].as_str().unwrap(), "stale-package-hash");
}
#[tokio::test]
async fn adds_entries_for_new_files() {
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/new.rs"), b"brand new").await.unwrap();
let starting = serde_json::json!({
"files": {
"Cargo.toml": "ff".repeat(32),
},
"package": "x",
});
tokio::fs::write(
pkg.join(CHECKSUM_FILE),
serde_json::to_string_pretty(&starting).unwrap(),
)
.await
.unwrap();
let _ = fixup(pkg, &["src/new.rs".to_string()]).await.unwrap();
let post: serde_json::Value = serde_json::from_str(
&tokio::fs::read_to_string(pkg.join(CHECKSUM_FILE)).await.unwrap(),
)
.unwrap();
let files = post["files"].as_object().unwrap();
assert_eq!(
files["src/new.rs"].as_str().unwrap(),
expected_sha256(b"brand new")
);
assert_eq!(files.len(), 2);
}
#[tokio::test]
async fn normalizes_package_prefix() {
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").await.unwrap();
let starting = serde_json::json!({
"files": { "src/lib.rs": "00".repeat(32) },
"package": "x",
});
tokio::fs::write(
pkg.join(CHECKSUM_FILE),
serde_json::to_string_pretty(&starting).unwrap(),
)
.await
.unwrap();
let _ = fixup(pkg, &["package/src/lib.rs".to_string()]).await.unwrap();
let post: serde_json::Value = serde_json::from_str(
&tokio::fs::read_to_string(pkg.join(CHECKSUM_FILE)).await.unwrap(),
)
.unwrap();
assert_eq!(
post["files"]["src/lib.rs"].as_str().unwrap(),
expected_sha256(b"patched")
);
assert!(post["files"].get("package/src/lib.rs").is_none());
}
#[tokio::test]
async fn missing_checksum_file_is_noop() {
let d = tempfile::tempdir().unwrap();
let out = fixup(d.path(), &["src/lib.rs".to_string()]).await.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn malformed_json_surfaces_error() {
let d = tempfile::tempdir().unwrap();
tokio::fs::write(d.path().join(CHECKSUM_FILE), b"this is not json")
.await
.unwrap();
let err = fixup(d.path(), &["src/lib.rs".to_string()])
.await
.unwrap_err();
assert!(matches!(err, SidecarError::Malformed { .. }));
}
}