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> {
if record.files.is_empty() {
return Err("no_files".to_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_not_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!(
out.applied.is_empty(),
"a zero-file patch must not be attested as applied"
);
assert_eq!(out.failed.len(), 1);
assert_eq!(out.failed[0].purl, "pkg:npm/empty@1.0.0");
assert_eq!(out.failed[0].reason, "no_files");
}
#[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}"
);
}
#[tokio::test]
async fn new_file_present_at_after_hash_is_applied() {
let pkg_dir = tempfile::tempdir().unwrap();
let created = b"freshly-created-file";
let hash = compute_git_sha256_from_bytes(created);
tokio::fs::write(pkg_dir.path().join("new.js"), created)
.await
.unwrap();
let mut files = HashMap::new();
files.insert(
"new.js".to_string(),
PatchFileInfo {
before_hash: String::new(), after_hash: hash,
},
);
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_eq!(out.applied, vec!["pkg:npm/x@1.0.0".to_string()]);
assert!(out.failed.is_empty());
}
#[tokio::test]
async fn new_file_absent_is_not_applied() {
let pkg_dir = tempfile::tempdir().unwrap();
let mut files = HashMap::new();
files.insert(
"new.js".to_string(),
PatchFileInfo {
before_hash: String::new(), after_hash:
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
.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");
}
#[tokio::test]
async fn noop_patch_before_equals_after_is_applied() {
let pkg_dir = tempfile::tempdir().unwrap();
let content = b"unchanged-content";
let hash = compute_git_sha256_from_bytes(content);
tokio::fs::write(pkg_dir.path().join("index.js"), content)
.await
.unwrap();
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash: hash.clone(),
after_hash: hash,
},
);
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_eq!(out.applied, vec!["pkg:npm/x@1.0.0".to_string()]);
assert!(out.failed.is_empty());
}
#[tokio::test]
async fn multi_file_all_patched_is_applied() {
let pkg_dir = tempfile::tempdir().unwrap();
let a = b"patched-a";
let b = b"patched-b";
let hash_a = compute_git_sha256_from_bytes(a);
let hash_b = compute_git_sha256_from_bytes(b);
tokio::fs::write(pkg_dir.path().join("a.js"), a).await.unwrap();
tokio::fs::write(pkg_dir.path().join("b.js"), 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: "bbbb".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_eq!(out.applied, vec!["pkg:npm/x@1.0.0".to_string()]);
assert!(out.failed.is_empty());
}
#[tokio::test]
async fn mixed_manifest_splits_into_both_buckets() {
let ok_dir = tempfile::tempdir().unwrap();
let patched = b"patched-content";
let hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(ok_dir.path().join("index.js"), patched)
.await
.unwrap();
let bad_dir = tempfile::tempdir().unwrap();
tokio::fs::write(bad_dir.path().join("index.js"), b"wrong")
.await
.unwrap();
let mut manifest = PatchManifest::new();
manifest
.patches
.insert("pkg:npm/ok@1.0.0".to_string(), record_with_one_file(&hash));
manifest.patches.insert(
"pkg:npm/bad@1.0.0".to_string(),
record_with_one_file(
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
),
);
let mut paths = HashMap::new();
paths.insert("pkg:npm/ok@1.0.0".to_string(), ok_dir.path().to_path_buf());
paths.insert("pkg:npm/bad@1.0.0".to_string(), bad_dir.path().to_path_buf());
let out = applied_patches(&manifest, &paths).await;
assert_eq!(out.applied, vec!["pkg:npm/ok@1.0.0".to_string()]);
assert_eq!(out.failed.len(), 1);
assert_eq!(out.failed[0].purl, "pkg:npm/bad@1.0.0");
assert_eq!(out.failed[0].reason, "hash_mismatch");
}
#[tokio::test]
async fn at_most_one_failure_recorded_per_purl() {
let pkg_dir = tempfile::tempdir().unwrap();
tokio::fs::write(pkg_dir.path().join("a.js"), b"garbage")
.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: "bbbb".to_string(), 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.len(), 1, "one FailedPatch per PURL, not per file");
assert!(
matches!(out.failed[0].reason.as_str(), "hash_mismatch" | "file_not_found"),
"unexpected reason: {}",
out.failed[0].reason
);
}
}