Skip to main content

fallow_cli/report/ci/
fingerprint.rs

1/// Fingerprint key used in SARIF partialFingerprints and other CI formats.
2pub const FINGERPRINT_KEY: &str = "tools.fallow.fingerprint/v1";
3
4/// Conventional SARIF key consumed by GitHub Code Scanning.
5pub const GHAS_FINGERPRINT_KEY: &str = "primaryLocationLineHash/v1";
6
7#[must_use]
8pub fn normalize_snippet(snippet: &str) -> String {
9    snippet
10        .lines()
11        .map(str::trim)
12        .filter(|line| !line.is_empty())
13        .collect::<Vec<_>>()
14        .join("\n")
15}
16
17/// Compute a deterministic fingerprint hash from key fields.
18#[must_use]
19pub fn fingerprint_hash(parts: &[&str]) -> String {
20    let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis
21    for part in parts {
22        for byte in part.bytes() {
23            hash ^= u64::from(byte);
24            hash = hash.wrapping_mul(0x0100_0000_01b3); // FNV prime
25        }
26        hash ^= 0xff;
27        hash = hash.wrapping_mul(0x0100_0000_01b3);
28    }
29    format!("{hash:016x}")
30}
31
32#[must_use]
33pub fn finding_fingerprint(rule_id: &str, path: &str, snippet: &str) -> String {
34    let normalized = normalize_snippet(snippet);
35    fingerprint_hash(&[rule_id, path, &normalized])
36}
37
38/// Stable fingerprint for the review envelope's top-level summary block.
39#[must_use]
40pub fn summary_fingerprint(body: &str) -> String {
41    fingerprint_hash(&[body])
42}
43
44/// Composite fingerprint for v2 same-line merged comments (issue #528).
45#[must_use]
46pub fn composite_fingerprint(constituents: &[&str]) -> String {
47    let mut sorted: Vec<&str> = constituents.to_vec();
48    sorted.sort_unstable();
49    let joined = sorted.join(":");
50    format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn fingerprint_is_stable_for_whitespace_only_snippet_changes() {
59        let a = finding_fingerprint(
60            "fallow/unused-export",
61            "src/a.ts",
62            "  export const x = 1;  ",
63        );
64        let b = finding_fingerprint(
65            "fallow/unused-export",
66            "src/a.ts",
67            "\nexport const x = 1;\n",
68        );
69        assert_eq!(a, b);
70    }
71
72    #[test]
73    fn fingerprint_parts_are_separated() {
74        assert_ne!(
75            fingerprint_hash(&["ab", "c"]),
76            fingerprint_hash(&["a", "bc"])
77        );
78    }
79
80    #[test]
81    fn composite_fingerprint_shifts_when_constituents_change() {
82        let three = composite_fingerprint(&["fp_a", "fp_b", "fp_c"]);
83        let drop_b = composite_fingerprint(&["fp_a", "fp_c"]);
84        let reordered = composite_fingerprint(&["fp_c", "fp_a", "fp_b"]);
85        assert_ne!(three, drop_b);
86        assert_eq!(three, reordered);
87        assert!(three.starts_with("merged:"));
88        assert_eq!(three.len(), 23);
89    }
90
91    #[test]
92    fn summary_fingerprint_shifts_when_body_changes() {
93        let a = summary_fingerprint("### Fallow check\n\n0 findings");
94        let b = summary_fingerprint("### Fallow check\n\n1 finding");
95        assert_ne!(a, b);
96        assert_eq!(a, summary_fingerprint("### Fallow check\n\n0 findings"));
97        assert_eq!(a.len(), 16);
98    }
99}