use std::path::Path;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::hash::git_sha256::compute_git_sha256_from_bytes;
use crate::patch::apply::{apply_file_patch, 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');
let expected_hash = compute_git_sha256_from_bytes(&out);
apply_file_patch(pkg_path, CHECKSUM_FILE, &out, &expected_hash)
.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 { .. }));
}
#[cfg(unix)]
#[tokio::test]
async fn rewrites_readonly_checksum_file_and_restores_mode() {
use std::os::unix::fs::PermissionsExt;
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": "stale",
});
let checksum = pkg.join(CHECKSUM_FILE);
tokio::fs::write(&checksum, serde_json::to_string_pretty(&starting).unwrap())
.await
.unwrap();
tokio::fs::set_permissions(&checksum, std::fs::Permissions::from_mode(0o444))
.await
.unwrap();
let out = fixup(pkg, &["src/lib.rs".to_string()]).await.unwrap();
assert!(out.is_some(), "read-only checksum must still be rewritten");
let post: serde_json::Value =
serde_json::from_str(&tokio::fs::read_to_string(&checksum).await.unwrap()).unwrap();
assert_eq!(
post["files"]["src/lib.rs"].as_str().unwrap(),
expected_sha256(b"patched lib")
);
let mode = tokio::fs::metadata(&checksum)
.await
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(
mode, 0o444,
"checksum file must stay read-only after rewrite"
);
}
#[cfg(unix)]
#[tokio::test]
async fn rewrites_inside_readonly_package_dir() {
use std::os::unix::fs::PermissionsExt;
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 checksum = pkg.join(CHECKSUM_FILE);
let starting = serde_json::json!({
"files": { "src/lib.rs": "00".repeat(32) },
"package": "x",
});
tokio::fs::write(&checksum, serde_json::to_string_pretty(&starting).unwrap())
.await
.unwrap();
tokio::fs::set_permissions(pkg, std::fs::Permissions::from_mode(0o555))
.await
.unwrap();
let out = fixup(pkg, &["src/lib.rs".to_string()]).await;
tokio::fs::set_permissions(pkg, std::fs::Permissions::from_mode(0o755))
.await
.unwrap();
assert!(
out.expect("fixup in read-only dir must not error")
.is_some(),
"read-only package dir must still be rewritten",
);
let post: serde_json::Value =
serde_json::from_str(&tokio::fs::read_to_string(&checksum).await.unwrap()).unwrap();
assert_eq!(
post["files"]["src/lib.rs"].as_str().unwrap(),
expected_sha256(b"patched lib")
);
}
#[tokio::test]
async fn rewrite_leaves_no_stage_litter() {
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(CHECKSUM_FILE),
serde_json::to_string_pretty(&starting).unwrap(),
)
.await
.unwrap();
fixup(pkg, &["src/lib.rs".to_string()]).await.unwrap();
let mut entries = tokio::fs::read_dir(pkg).await.unwrap();
while let Some(entry) = entries.next_entry().await.unwrap() {
let name = entry.file_name().to_string_lossy().to_string();
assert!(
!name.starts_with(".socket-stage-") && !name.starts_with(".socket-cow-"),
"stage/cow litter leaked into package dir: {name}"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn rewrite_does_not_mutate_hardlinked_sibling() {
let d = tempfile::tempdir().unwrap();
let pkg = d.path().join("pkg");
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",
});
let checksum = pkg.join(CHECKSUM_FILE);
let original_json = serde_json::to_string_pretty(&starting).unwrap();
tokio::fs::write(&checksum, &original_json).await.unwrap();
let sibling = d.path().join("shared-store-checksum.json");
tokio::fs::hard_link(&checksum, &sibling).await.unwrap();
fixup(&pkg, &["src/lib.rs".to_string()]).await.unwrap();
let post: serde_json::Value =
serde_json::from_str(&tokio::fs::read_to_string(&checksum).await.unwrap()).unwrap();
assert_eq!(
post["files"]["src/lib.rs"].as_str().unwrap(),
expected_sha256(b"patched lib")
);
assert_eq!(
tokio::fs::read_to_string(&sibling).await.unwrap(),
original_json,
);
}
}