use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::manifest::schema::PatchManifest;
use crate::patch::apply::{verify_file_patch, VerifyStatus};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FailedPatch {
pub purl: String,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub struct VerifyOutcome {
pub applied: Vec<String>,
pub failed: Vec<FailedPatch>,
}
pub async fn applied_patches(
manifest: &PatchManifest,
package_paths: &HashMap<String, PathBuf>,
) -> VerifyOutcome {
let mut out = VerifyOutcome::default();
for (purl, record) in &manifest.patches {
let pkg_path = match package_paths.get(purl) {
Some(p) => p,
None => {
out.failed.push(FailedPatch {
purl: purl.clone(),
reason: "package_not_found".to_string(),
});
continue;
}
};
match verify_patch_record(pkg_path, record).await {
Ok(()) => out.applied.push(purl.clone()),
Err(reason) => out.failed.push(FailedPatch {
purl: purl.clone(),
reason,
}),
}
}
out
}
async fn verify_patch_record(
pkg_path: &Path,
record: &crate::manifest::schema::PatchRecord,
) -> Result<(), String> {
for (file_name, file_info) in &record.files {
let result = verify_file_patch(pkg_path, file_name, file_info).await;
match result.status {
VerifyStatus::AlreadyPatched => continue,
VerifyStatus::Ready => return Err("not_applied".to_string()),
VerifyStatus::HashMismatch => return Err("hash_mismatch".to_string()),
VerifyStatus::NotFound => return Err("file_not_found".to_string()),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash::git_sha256::compute_git_sha256_from_bytes;
use crate::manifest::schema::{PatchFileInfo, PatchRecord};
use std::collections::HashMap;
fn record_with_one_file(after_hash: &str) -> PatchRecord {
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash: "aaaa".to_string(),
after_hash: after_hash.to_string(),
},
);
PatchRecord {
uuid: "u".to_string(),
exported_at: "2024-01-01T00:00:00Z".to_string(),
files,
vulnerabilities: HashMap::new(),
description: String::new(),
license: String::new(),
tier: String::new(),
}
}
#[tokio::test]
async fn applied_when_all_files_match_after_hash() {
let pkg_dir = tempfile::tempdir().unwrap();
let patched = b"patched-content";
let hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(pkg_dir.path().join("index.js"), patched)
.await
.unwrap();
let mut manifest = PatchManifest::new();
manifest
.patches
.insert("pkg:npm/x@1.0.0".to_string(), record_with_one_file(&hash));
let mut paths = HashMap::new();
paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
let out = applied_patches(&manifest, &paths).await;
assert_eq!(out.applied, vec!["pkg:npm/x@1.0.0".to_string()]);
assert!(out.failed.is_empty());
}
#[tokio::test]
async fn missing_path_falls_into_failed() {
let mut manifest = PatchManifest::new();
manifest
.patches
.insert("pkg:npm/x@1.0.0".to_string(), record_with_one_file("deadbeef"));
let paths: HashMap<String, PathBuf> = HashMap::new();
let out = applied_patches(&manifest, &paths).await;
assert!(out.applied.is_empty());
assert_eq!(out.failed.len(), 1);
assert_eq!(out.failed[0].reason, "package_not_found");
}
#[tokio::test]
async fn hash_mismatch_falls_into_failed() {
let pkg_dir = tempfile::tempdir().unwrap();
tokio::fs::write(pkg_dir.path().join("index.js"), b"not the right content")
.await
.unwrap();
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
record_with_one_file("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
);
let mut paths = HashMap::new();
paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
let out = applied_patches(&manifest, &paths).await;
assert!(out.applied.is_empty());
assert_eq!(out.failed[0].reason, "hash_mismatch");
}
#[tokio::test]
async fn missing_file_falls_into_failed() {
let pkg_dir = tempfile::tempdir().unwrap();
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
record_with_one_file("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
);
let mut paths = HashMap::new();
paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
let out = applied_patches(&manifest, &paths).await;
assert_eq!(out.failed[0].reason, "file_not_found");
}
#[tokio::test]
async fn partial_apply_still_fails() {
let pkg_dir = tempfile::tempdir().unwrap();
let patched_a = b"AAA";
let hash_a = compute_git_sha256_from_bytes(patched_a);
let original_b = b"original-b";
let before_b = compute_git_sha256_from_bytes(original_b);
tokio::fs::write(pkg_dir.path().join("a.js"), patched_a)
.await
.unwrap();
tokio::fs::write(pkg_dir.path().join("b.js"), original_b)
.await
.unwrap();
let mut files = HashMap::new();
files.insert(
"a.js".to_string(),
PatchFileInfo {
before_hash: "aaaa".to_string(),
after_hash: hash_a,
},
);
files.insert(
"b.js".to_string(),
PatchFileInfo {
before_hash: before_b,
after_hash: "deadbeef".to_string(),
},
);
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
PatchRecord {
uuid: "u".to_string(),
exported_at: String::new(),
files,
vulnerabilities: HashMap::new(),
description: String::new(),
license: String::new(),
tier: String::new(),
},
);
let mut paths = HashMap::new();
paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
let out = applied_patches(&manifest, &paths).await;
assert!(out.applied.is_empty());
assert_eq!(out.failed[0].reason, "not_applied");
}
#[test]
fn outcome_default_is_empty() {
let o = VerifyOutcome::default();
assert!(o.applied.is_empty());
assert!(o.failed.is_empty());
}
#[test]
fn failed_patch_value_semantics() {
let a = FailedPatch {
purl: "pkg:npm/x@1".to_string(),
reason: "hash_mismatch".to_string(),
};
let b = a.clone();
assert_eq!(a, b);
}
#[tokio::test]
async fn empty_manifest_returns_empty_outcome() {
let manifest = PatchManifest::new();
let paths: HashMap<String, PathBuf> = HashMap::new();
let out = applied_patches(&manifest, &paths).await;
assert!(out.applied.is_empty());
assert!(out.failed.is_empty());
}
#[tokio::test]
async fn patch_record_with_zero_files_is_vacuously_applied() {
let pkg_dir = tempfile::tempdir().unwrap();
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/empty@1.0.0".to_string(),
PatchRecord {
uuid: "u".to_string(),
exported_at: String::new(),
files: HashMap::new(),
vulnerabilities: HashMap::new(),
description: String::new(),
license: String::new(),
tier: String::new(),
},
);
let mut paths = HashMap::new();
paths.insert(
"pkg:npm/empty@1.0.0".to_string(),
pkg_dir.path().to_path_buf(),
);
let out = applied_patches(&manifest, &paths).await;
assert_eq!(out.applied, vec!["pkg:npm/empty@1.0.0".to_string()]);
assert!(out.failed.is_empty());
}
#[tokio::test]
async fn extra_package_paths_are_ignored() {
let pkg_dir = tempfile::tempdir().unwrap();
let patched = b"patched";
let hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(pkg_dir.path().join("index.js"), patched)
.await
.unwrap();
let mut manifest = PatchManifest::new();
manifest
.patches
.insert("pkg:npm/x@1.0.0".to_string(), record_with_one_file(&hash));
let mut paths = HashMap::new();
paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
paths.insert(
"pkg:npm/stray@9.9.9".to_string(),
pkg_dir.path().to_path_buf(),
);
let out = applied_patches(&manifest, &paths).await;
assert_eq!(out.applied.len(), 1);
assert_eq!(out.applied[0], "pkg:npm/x@1.0.0");
assert!(out.failed.is_empty());
}
#[tokio::test]
async fn multi_file_first_failure_short_circuits() {
let pkg_dir = tempfile::tempdir().unwrap();
tokio::fs::write(pkg_dir.path().join("a.js"), b"garbage")
.await
.unwrap();
let patched_b = b"patched-b";
let hash_b = compute_git_sha256_from_bytes(patched_b);
tokio::fs::write(pkg_dir.path().join("b.js"), patched_b)
.await
.unwrap();
let mut files = HashMap::new();
files.insert(
"a.js".to_string(),
PatchFileInfo {
before_hash: "aaaa".to_string(),
after_hash: "deadbeef".to_string(),
},
);
files.insert(
"b.js".to_string(),
PatchFileInfo {
before_hash: "cccc".to_string(),
after_hash: hash_b,
},
);
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
PatchRecord {
uuid: "u".to_string(),
exported_at: String::new(),
files,
vulnerabilities: HashMap::new(),
description: String::new(),
license: String::new(),
tier: String::new(),
},
);
let mut paths = HashMap::new();
paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
let out = applied_patches(&manifest, &paths).await;
assert!(out.applied.is_empty());
assert_eq!(out.failed.len(), 1, "first failure must short-circuit");
let reason = &out.failed[0].reason;
assert!(
matches!(reason.as_str(), "hash_mismatch" | "not_applied"),
"unexpected reason: {reason}"
);
}
}