use super::baseline::BaselineEntry;
use super::types::DiffEntry;
use crate::findings::Finding;
use sha2::{Digest, Sha256};
pub(crate) const MIN_RELATIVE_SUFFIX_COMPONENTS: usize = 3;
pub fn finding_fingerprint(finding: &Finding) -> String {
let mut hasher = Sha256::new();
hasher.update(finding.rule_id.as_bytes());
hasher.update(b"\0");
hasher.update(
finding
.artifact_path
.as_deref()
.unwrap_or("\0<missing-path>")
.as_bytes(),
);
hasher.update(b"\0");
hasher.update(finding.match_value.to_ascii_lowercase().as_bytes());
hasher.update(b"\0");
hasher.update(finding.matched_on.to_string().as_bytes());
format!("{:x}", hasher.finalize())
}
pub(crate) fn paths_match(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let pa = std::path::Path::new(a);
let pb = std::path::Path::new(b);
if pa.is_absolute() && pb.is_absolute() {
return false;
}
let meaningful = |p: &std::path::Path| -> usize {
p.components()
.filter(|c| !matches!(c, std::path::Component::RootDir))
.count()
};
let a_components = meaningful(pa);
let b_components = meaningful(pb);
if b_components >= MIN_RELATIVE_SUFFIX_COMPONENTS && pa.ends_with(pb) {
return true;
}
if a_components >= MIN_RELATIVE_SUFFIX_COMPONENTS && pb.ends_with(pa) {
return true;
}
false
}
pub(crate) fn baseline_matches_finding(entry: &BaselineEntry, finding: &Finding) -> bool {
entry.fingerprint == finding_fingerprint(finding)
}
pub(crate) fn finding_to_diff_entry(finding: &Finding) -> DiffEntry {
DiffEntry {
fingerprint: finding_fingerprint(finding),
rule_id: finding.rule_id.clone(),
category: finding.category,
artifact_path: finding.artifact_path.clone(),
reason: finding.reason.clone(),
}
}
#[cfg(test)]
mod paths_match_tests {
use super::paths_match;
#[test]
fn paths_match_rejects_two_component_cross_package_match() {
assert!(
!paths_match("/repo-a/config/settings.py", "config/settings.py"),
"2-component relative waivers MUST NOT span packages"
);
assert!(
!paths_match("config/settings.py", "/repo-b/config/settings.py"),
"symmetric: shorter side can't match a longer absolute path with only 2 components"
);
}
#[test]
fn paths_match_accepts_three_component_specific_suffix() {
assert!(
paths_match(
"/some/dir/repo-a/config/settings.py",
"repo-a/config/settings.py"
),
"3-component relative path SHOULD suffix-match a longer absolute path"
);
}
#[test]
fn paths_match_rejects_absolute_suffix_match() {
assert!(!paths_match("/baz/foo/bar.py", "/foo/bar.py"));
}
#[test]
fn paths_match_exact_equality() {
assert!(paths_match("a/b/c.py", "a/b/c.py"));
assert!(paths_match("/abs/path", "/abs/path"));
}
#[test]
fn paths_match_bare_filename_never_matches_across_dirs() {
assert!(!paths_match("/repo/x/skill.md", "skill.md"));
}
}
#[cfg(test)]
mod fingerprint_tests {
use super::*;
use crate::findings::{ArtifactKind, MatchTarget, ThreatCategory};
fn finding(
rule_id: &str,
artifact_path: Option<&str>,
match_value: &str,
reason: &str,
matched_on: MatchTarget,
) -> Finding {
Finding::builder(rule_id, ThreatCategory::Generic)
.match_value(match_value)
.reason(reason)
.matched_on(matched_on)
.artifact(
ArtifactKind::SkillDocument,
artifact_path.map(ToOwned::to_owned),
)
.build()
}
#[test]
fn finding_fingerprint_is_deterministic() {
let a = finding(
"RULE_A",
Some("pkg/src/main.rs"),
"payload",
"reason text",
MatchTarget::Document,
);
let b = finding(
"RULE_A",
Some("pkg/src/main.rs"),
"payload",
"reason text",
MatchTarget::Document,
);
assert_eq!(finding_fingerprint(&a), finding_fingerprint(&b));
}
#[test]
fn finding_fingerprint_excludes_reason() {
let a = finding(
"RULE_A",
Some("pkg/src/main.rs"),
"payload",
"old wording",
MatchTarget::Document,
);
let b = finding(
"RULE_A",
Some("pkg/src/main.rs"),
"payload",
"new wording — same detection",
MatchTarget::Document,
);
assert_eq!(
finding_fingerprint(&a),
finding_fingerprint(&b),
"reason MUST NOT enter the fingerprint hash"
);
}
#[test]
fn finding_fingerprint_includes_rule_id() {
let a = finding(
"RULE_A",
Some("p/q/r.rs"),
"x",
"reason",
MatchTarget::Document,
);
let b = finding(
"RULE_B",
Some("p/q/r.rs"),
"x",
"reason",
MatchTarget::Document,
);
assert_ne!(finding_fingerprint(&a), finding_fingerprint(&b));
}
#[test]
fn finding_fingerprint_includes_artifact_path() {
let a = finding("RULE_A", Some("p/a.rs"), "x", "r", MatchTarget::Document);
let b = finding("RULE_A", Some("p/b.rs"), "x", "r", MatchTarget::Document);
assert_ne!(finding_fingerprint(&a), finding_fingerprint(&b));
}
#[test]
fn finding_fingerprint_includes_match_value() {
let a = finding(
"RULE_A",
Some("p/q/r.rs"),
"payload-1",
"r",
MatchTarget::Document,
);
let b = finding(
"RULE_A",
Some("p/q/r.rs"),
"payload-2",
"r",
MatchTarget::Document,
);
assert_ne!(finding_fingerprint(&a), finding_fingerprint(&b));
}
#[test]
fn finding_fingerprint_includes_matched_on() {
let a = finding(
"RULE_A",
Some("p/q/r.rs"),
"payload",
"r",
MatchTarget::Document,
);
let b = finding(
"RULE_A",
Some("p/q/r.rs"),
"payload",
"r",
MatchTarget::CodeBlock { language: None },
);
assert_ne!(finding_fingerprint(&a), finding_fingerprint(&b));
}
#[test]
fn finding_fingerprint_handles_missing_artifact_path() {
let a = finding("RULE_A", None, "x", "r", MatchTarget::Document);
let b = finding("RULE_A", None, "x", "r", MatchTarget::Document);
assert_eq!(finding_fingerprint(&a), finding_fingerprint(&b));
}
#[test]
fn finding_fingerprint_returns_lowercase_64_hex() {
let f = finding("RULE_A", Some("p/q/r.rs"), "x", "r", MatchTarget::Document);
let fp = finding_fingerprint(&f);
assert_eq!(fp.len(), 64);
assert!(
fp.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
"fingerprint must be lowercase hex; got `{fp}`"
);
}
#[test]
fn finding_fingerprint_normalizes_match_value_case() {
let a = finding(
"RULE_A",
Some("p/q/r.rs"),
"https://Evil.COM/x",
"r",
MatchTarget::Document,
);
let b = finding(
"RULE_A",
Some("p/q/r.rs"),
"https://evil.com/x",
"r",
MatchTarget::Document,
);
assert_eq!(
finding_fingerprint(&a),
finding_fingerprint(&b),
"match_value casing MUST NOT affect the fingerprint"
);
}
}
#[cfg(test)]
mod baseline_matches_tests {
use super::*;
use crate::findings::{ArtifactKind, Finding, ThreatCategory};
fn finding_for(rule_id: &str, artifact_path: Option<&str>) -> Finding {
Finding::builder(rule_id, ThreatCategory::Generic)
.match_value("payload")
.reason("test")
.artifact(
ArtifactKind::SkillDocument,
artifact_path.map(ToOwned::to_owned),
)
.build()
}
#[test]
fn baseline_matches_finding_requires_fingerprint_equality() {
let finding = finding_for("RULE_A", Some("pkg/src/main.rs"));
let entry = BaselineEntry {
fingerprint: finding_fingerprint(&finding),
rule_id: finding.rule_id.clone(),
artifact_path: finding.artifact_path.clone(),
reason: finding.reason.clone(),
};
assert!(
baseline_matches_finding(&entry, &finding),
"fingerprint-equal entry MUST match"
);
}
#[test]
fn baseline_matches_finding_does_not_apply_paths_match_suffix() {
let finding = finding_for("RULE_A", Some("/abs/repo/pkg/src/main.rs"));
let suffix_finding = finding_for("RULE_A", Some("pkg/src/main.rs"));
let entry = BaselineEntry {
fingerprint: finding_fingerprint(&suffix_finding),
rule_id: suffix_finding.rule_id.clone(),
artifact_path: suffix_finding.artifact_path.clone(),
reason: suffix_finding.reason.clone(),
};
assert!(
!baseline_matches_finding(&entry, &finding),
"suffix-equivalent paths must NOT match — baselines are fingerprint-exact"
);
}
#[test]
fn baseline_matches_finding_rejects_different_rule_id() {
let finding_a = finding_for("RULE_A", Some("pkg/src/main.rs"));
let finding_b = finding_for("RULE_B", Some("pkg/src/main.rs"));
let entry = BaselineEntry {
fingerprint: finding_fingerprint(&finding_a),
rule_id: finding_a.rule_id.clone(),
artifact_path: finding_a.artifact_path.clone(),
reason: finding_a.reason.clone(),
};
assert!(!baseline_matches_finding(&entry, &finding_b));
}
}