Skip to main content

diffguard_core/
fingerprint.rs

1//! Stable fingerprint computation for findings.
2//!
3//! Fingerprints provide a stable identifier for findings across runs,
4//! enabling deduplication and tracking.
5
6use diffguard_types::Finding;
7use sha2::{Digest, Sha256};
8
9/// Computes a stable fingerprint for a finding.
10///
11/// The fingerprint is a full SHA-256 hash of `rule_id:path:line:match_text`
12/// (64 hex characters / 32 bytes).
13pub fn compute_fingerprint(f: &Finding) -> String {
14    let input = format!("{}:{}:{}:{}", f.rule_id, f.path, f.line, f.match_text);
15    compute_fingerprint_raw(&input)
16}
17
18/// Computes a full SHA-256 fingerprint from an arbitrary input string.
19///
20/// Returns 64 hex characters (32 bytes).
21pub fn compute_fingerprint_raw(input: &str) -> String {
22    let hash = Sha256::digest(input.as_bytes());
23    hex::encode(hash)
24}
25
26#[cfg(test)]
27mod tests {
28    use super::*;
29    use diffguard_types::Severity;
30
31    fn test_finding() -> Finding {
32        Finding {
33            rule_id: "rust.no_unwrap".to_string(),
34            severity: Severity::Error,
35            message: "Avoid unwrap".to_string(),
36            path: "src/lib.rs".to_string(),
37            line: 42,
38            column: Some(10),
39            match_text: ".unwrap()".to_string(),
40            snippet: "let x = foo.unwrap();".to_string(),
41        }
42    }
43
44    #[test]
45    fn fingerprint_is_64_hex_chars() {
46        let f = test_finding();
47        let fp = compute_fingerprint(&f);
48        assert_eq!(fp.len(), 64);
49        assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
50    }
51
52    #[test]
53    fn fingerprint_is_stable() {
54        let f = test_finding();
55        let fp1 = compute_fingerprint(&f);
56        let fp2 = compute_fingerprint(&f);
57        assert_eq!(fp1, fp2);
58    }
59
60    #[test]
61    fn fingerprint_differs_for_different_rule_id() {
62        let f1 = test_finding();
63        let mut f2 = test_finding();
64        f2.rule_id = "rust.no_dbg".to_string();
65
66        assert_ne!(compute_fingerprint(&f1), compute_fingerprint(&f2));
67    }
68
69    #[test]
70    fn fingerprint_differs_for_different_path() {
71        let f1 = test_finding();
72        let mut f2 = test_finding();
73        f2.path = "src/main.rs".to_string();
74
75        assert_ne!(compute_fingerprint(&f1), compute_fingerprint(&f2));
76    }
77
78    #[test]
79    fn fingerprint_differs_for_different_line() {
80        let f1 = test_finding();
81        let mut f2 = test_finding();
82        f2.line = 100;
83
84        assert_ne!(compute_fingerprint(&f1), compute_fingerprint(&f2));
85    }
86
87    #[test]
88    fn fingerprint_differs_for_different_match_text() {
89        let f1 = test_finding();
90        let mut f2 = test_finding();
91        f2.match_text = ".expect()".to_string();
92
93        assert_ne!(compute_fingerprint(&f1), compute_fingerprint(&f2));
94    }
95
96    #[test]
97    fn fingerprint_ignores_severity() {
98        let f1 = test_finding();
99        let mut f2 = test_finding();
100        f2.severity = Severity::Warn;
101
102        // Severity is not part of the fingerprint
103        assert_eq!(compute_fingerprint(&f1), compute_fingerprint(&f2));
104    }
105
106    #[test]
107    fn fingerprint_ignores_message() {
108        let f1 = test_finding();
109        let mut f2 = test_finding();
110        f2.message = "Different message".to_string();
111
112        // Message is not part of the fingerprint
113        assert_eq!(compute_fingerprint(&f1), compute_fingerprint(&f2));
114    }
115
116    #[test]
117    fn snapshot_fingerprint_value() {
118        let f = test_finding();
119        let fp = compute_fingerprint(&f);
120        // This ensures the fingerprint algorithm doesn't change unexpectedly
121        insta::assert_snapshot!(fp, @"d559ee3767f8ccda27b477039711c881d44c366e3bd8ea119649746bdff1a0b8");
122    }
123
124    #[test]
125    fn compute_fingerprint_raw_produces_64_hex() {
126        let fp = super::compute_fingerprint_raw("test:input");
127        assert_eq!(fp.len(), 64);
128        assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
129    }
130}