Skip to content

Commit 520f2cf

Browse files
feat(diffguard-core): add #[must_use] to compute_fingerprint functions (#1571)
* feat(diffguard-core): add #[must_use] to compute_fingerprint functions Addresses issue #518 - silent data loss when callers discard fingerprint result. Both public fingerprint functions now have #[must_use]: - compute_fingerprint(f: &Finding) -> String - compute_fingerprint_raw(input: &str) -> String This aligns with the team convention established in diffguard-analytics which already has #[must_use] on fingerprint_for_finding(). * test(diffguard-core): add edge case tests for fingerprint functions Add 36 edge case tests covering: - Empty strings for all Finding fields - Unicode characters (emoji, CJK, etc.) - Very long inputs (100K+ chars) - Special characters (colons, newlines, null bytes) - Zero and boundary line numbers - Known SHA-256 hash verification - Confirmation that column/message/snippet/severity are NOT in fingerprint hash
1 parent 6f562eb commit 520f2cf

2 files changed

Lines changed: 385 additions & 0 deletions

File tree

‎crates/diffguard-core/src/fingerprint.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use sha2::{Digest, Sha256};
1010
///
1111
/// The fingerprint is a full SHA-256 hash of `rule_id:path:line:match_text`
1212
/// (64 hex characters / 32 bytes).
13+
#[must_use]
1314
pub fn compute_fingerprint(f: &Finding) -> String {
1415
let input = format!("{}:{}:{}:{}", f.rule_id, f.path, f.line, f.match_text);
1516
compute_fingerprint_raw(&input)
@@ -18,6 +19,7 @@ pub fn compute_fingerprint(f: &Finding) -> String {
1819
/// Computes a full SHA-256 fingerprint from an arbitrary input string.
1920
///
2021
/// Returns 64 hex characters (32 bytes).
22+
#[must_use]
2123
pub fn compute_fingerprint_raw(input: &str) -> String {
2224
let hash = Sha256::digest(input.as_bytes());
2325
hex::encode(hash)
Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
//! Green tests: Edge case coverage for fingerprint functions.
2+
//!
3+
//! These tests verify the fingerprint functions handle edge cases correctly:
4+
//! - Empty strings
5+
//! - Unicode characters
6+
//! - Special characters (colons, newlines, etc.)
7+
//! - Very long inputs
8+
//! - Column None vs Some
9+
//! - Zero/boundary values
10+
//!
11+
//! Issue: GitHub #518
12+
13+
use diffguard_core::{compute_fingerprint, compute_fingerprint_raw};
14+
use diffguard_types::Finding;
15+
use diffguard_types::Severity;
16+
17+
/// Helper to create a minimal finding with customizable fields.
18+
fn make_finding(rule_id: &str, path: &str, line: u32, match_text: &str) -> Finding {
19+
Finding {
20+
rule_id: rule_id.to_string(),
21+
severity: Severity::Error,
22+
message: "Test message".to_string(),
23+
path: path.to_string(),
24+
line,
25+
column: Some(1),
26+
match_text: match_text.to_string(),
27+
snippet: match_text.to_string(),
28+
}
29+
}
30+
31+
// =============================================================================
32+
// Empty string edge cases
33+
// =============================================================================
34+
35+
#[test]
36+
fn test_fingerprint_empty_rule_id() {
37+
let f = make_finding("", "src/lib.rs", 1, ".unwrap()");
38+
let fp = compute_fingerprint(&f);
39+
assert_eq!(fp.len(), 64);
40+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
41+
}
42+
43+
#[test]
44+
fn test_fingerprint_empty_path() {
45+
let f = make_finding("rust.no_unwrap", "", 1, ".unwrap()");
46+
let fp = compute_fingerprint(&f);
47+
assert_eq!(fp.len(), 64);
48+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
49+
}
50+
51+
#[test]
52+
fn test_fingerprint_empty_match_text() {
53+
let f = make_finding("rust.no_unwrap", "src/lib.rs", 1, "");
54+
let fp = compute_fingerprint(&f);
55+
assert_eq!(fp.len(), 64);
56+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
57+
}
58+
59+
#[test]
60+
fn test_fingerprint_raw_empty_string() {
61+
let fp = compute_fingerprint_raw("");
62+
assert_eq!(fp.len(), 64);
63+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
64+
}
65+
66+
// =============================================================================
67+
// Zero and boundary values
68+
// =============================================================================
69+
70+
#[test]
71+
fn test_fingerprint_line_zero() {
72+
let f = make_finding("rust.no_unwrap", "src/lib.rs", 0, ".unwrap()");
73+
let fp = compute_fingerprint(&f);
74+
assert_eq!(fp.len(), 64);
75+
}
76+
77+
#[test]
78+
fn test_fingerprint_column_none() {
79+
let mut f = make_finding("rust.no_unwrap", "src/lib.rs", 1, ".unwrap()");
80+
f.column = None;
81+
let fp = compute_fingerprint(&f);
82+
assert_eq!(fp.len(), 64);
83+
}
84+
85+
#[test]
86+
fn test_fingerprint_column_some_vs_none_affects_fingerprint() {
87+
// Column is NOT part of the fingerprint (only rule_id, path, line, match_text)
88+
let mut f_with_column = make_finding("rust.no_unwrap", "src/lib.rs", 1, ".unwrap()");
89+
f_with_column.column = Some(10);
90+
91+
let mut f_without_column = make_finding("rust.no_unwrap", "src/lib.rs", 1, ".unwrap()");
92+
f_without_column.column = None;
93+
94+
// Both should produce the same fingerprint since column is not part of the hash input
95+
let fp_with = compute_fingerprint(&f_with_column);
96+
let fp_without = compute_fingerprint(&f_without_column);
97+
assert_eq!(fp_with, fp_without);
98+
}
99+
100+
// =============================================================================
101+
// Special characters in input
102+
// =============================================================================
103+
104+
#[test]
105+
fn test_fingerprint_raw_colons_in_input() {
106+
// Colons are used as separators in the format! string
107+
// but compute_fingerprint_raw takes arbitrary strings
108+
let fp = compute_fingerprint_raw("a:b:c:d:e:f");
109+
assert_eq!(fp.len(), 64);
110+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
111+
}
112+
113+
#[test]
114+
fn test_fingerprint_raw_newlines_in_input() {
115+
let fp = compute_fingerprint_raw("line1\nline2\nline3");
116+
assert_eq!(fp.len(), 64);
117+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
118+
}
119+
120+
#[test]
121+
fn test_fingerprint_raw_tabs_in_input() {
122+
let fp = compute_fingerprint_raw("col1\tcol2\tcol3");
123+
assert_eq!(fp.len(), 64);
124+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
125+
}
126+
127+
#[test]
128+
fn test_fingerprint_raw_carriage_return_in_input() {
129+
let fp = compute_fingerprint_raw("line1\r\nline2");
130+
assert_eq!(fp.len(), 64);
131+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
132+
}
133+
134+
#[test]
135+
fn test_fingerprint_raw_null_byte_in_input() {
136+
let fp = compute_fingerprint_raw("hello\0world");
137+
assert_eq!(fp.len(), 64);
138+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
139+
}
140+
141+
// =============================================================================
142+
// Unicode edge cases
143+
// =============================================================================
144+
145+
#[test]
146+
fn test_fingerprint_unicode_in_rule_id() {
147+
let f = make_finding("rust.no_ünwrap", "src/lib.rs", 1, ".unwrap()");
148+
let fp = compute_fingerprint(&f);
149+
assert_eq!(fp.len(), 64);
150+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
151+
}
152+
153+
#[test]
154+
fn test_fingerprint_unicode_in_path() {
155+
let f = make_finding("rust.no_unwrap", "src/üniçodé.rs", 1, ".unwrap()");
156+
let fp = compute_fingerprint(&f);
157+
assert_eq!(fp.len(), 64);
158+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
159+
}
160+
161+
#[test]
162+
fn test_fingerprint_unicode_in_match_text() {
163+
let f = make_finding("rust.no_unwrap", "src/lib.rs", 1, ".ünwrap()");
164+
let fp = compute_fingerprint(&f);
165+
assert_eq!(fp.len(), 64);
166+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
167+
}
168+
169+
#[test]
170+
fn test_fingerprint_raw_unicode_full_width_chars() {
171+
let fp = compute_fingerprint_raw("中文русскийالعربية");
172+
assert_eq!(fp.len(), 64);
173+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
174+
}
175+
176+
#[test]
177+
fn test_fingerprint_raw_unicode_emoji() {
178+
let fp = compute_fingerprint_raw("Hello 👋 World 🌍");
179+
assert_eq!(fp.len(), 64);
180+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
181+
}
182+
183+
// =============================================================================
184+
// Long input edge cases
185+
// =============================================================================
186+
187+
#[test]
188+
fn test_fingerprint_raw_very_long_input() {
189+
let long_input = "a".repeat(100_000);
190+
let fp = compute_fingerprint_raw(&long_input);
191+
assert_eq!(fp.len(), 64);
192+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
193+
}
194+
195+
#[test]
196+
fn test_fingerprint_long_rule_id() {
197+
let long_rule_id = "a".repeat(10_000);
198+
let f = make_finding(&long_rule_id, "src/lib.rs", 1, ".unwrap()");
199+
let fp = compute_fingerprint(&f);
200+
assert_eq!(fp.len(), 64);
201+
}
202+
203+
#[test]
204+
fn test_fingerprint_long_path() {
205+
let long_path = "src/".to_string() + &"a".repeat(10_000) + "/lib.rs";
206+
let f = make_finding("rust.no_unwrap", &long_path, 1, ".unwrap()");
207+
let fp = compute_fingerprint(&f);
208+
assert_eq!(fp.len(), 64);
209+
}
210+
211+
#[test]
212+
fn test_fingerprint_long_match_text() {
213+
let long_match = "x".repeat(10_000);
214+
let f = make_finding("rust.no_unwrap", "src/lib.rs", 1, &long_match);
215+
let fp = compute_fingerprint(&f);
216+
assert_eq!(fp.len(), 64);
217+
}
218+
219+
// =============================================================================
220+
// Very large line numbers
221+
// =============================================================================
222+
223+
#[test]
224+
fn test_fingerprint_large_line_number() {
225+
let f = make_finding("rust.no_unwrap", "src/lib.rs", u32::MAX, ".unwrap()");
226+
let fp = compute_fingerprint(&f);
227+
assert_eq!(fp.len(), 64);
228+
}
229+
230+
#[test]
231+
fn test_fingerprint_line_number_one() {
232+
// Line 1 is a common boundary case (first line of a file)
233+
let f = make_finding("rust.no_unwrap", "src/lib.rs", 1, ".unwrap()");
234+
let fp = compute_fingerprint(&f);
235+
assert_eq!(fp.len(), 64);
236+
}
237+
238+
// =============================================================================
239+
// ASCII boundary characters
240+
// =============================================================================
241+
242+
#[test]
243+
fn test_fingerprint_raw_all_hex_chars() {
244+
// Input that is already valid hex should still produce hex output
245+
let fp = compute_fingerprint_raw("0123456789abcdefABCDEF");
246+
assert_eq!(fp.len(), 64);
247+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
248+
}
249+
250+
#[test]
251+
fn test_fingerprint_raw_special_chars_only() {
252+
let fp = compute_fingerprint_raw("!@#$%^&*()_+-=[]{}|;':\",./<>?");
253+
assert_eq!(fp.len(), 64);
254+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
255+
}
256+
257+
#[test]
258+
fn test_fingerprint_raw_backslash_and_escape() {
259+
let fp = compute_fingerprint_raw("path\\with\\backslashes\nand\tescape\tchars");
260+
assert_eq!(fp.len(), 64);
261+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
262+
}
263+
264+
// =============================================================================
265+
// Single character inputs
266+
// =============================================================================
267+
268+
#[test]
269+
fn test_fingerprint_raw_single_char() {
270+
let fp = compute_fingerprint_raw("a");
271+
assert_eq!(fp.len(), 64);
272+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
273+
}
274+
275+
#[test]
276+
fn test_fingerprint_single_char_match_text() {
277+
let f = make_finding("r", "p", 1, "x");
278+
let fp = compute_fingerprint(&f);
279+
assert_eq!(fp.len(), 64);
280+
}
281+
282+
// =============================================================================
283+
// Different findings produce different fingerprints
284+
// =============================================================================
285+
286+
#[test]
287+
fn test_fingerprint_different_columns_same_fingerprint() {
288+
// Column is NOT part of the fingerprint - this confirms the behavior
289+
let mut f1 = make_finding("rust.no_unwrap", "src/lib.rs", 42, ".unwrap()");
290+
let mut f2 = make_finding("rust.no_unwrap", "src/lib.rs", 42, ".unwrap()");
291+
f1.column = Some(5);
292+
f2.column = Some(100);
293+
294+
assert_eq!(compute_fingerprint(&f1), compute_fingerprint(&f2));
295+
}
296+
297+
#[test]
298+
fn test_fingerprint_different_message_same_fingerprint() {
299+
// Message is NOT part of the fingerprint
300+
let mut f1 = make_finding("rust.no_unwrap", "src/lib.rs", 42, ".unwrap()");
301+
let mut f2 = make_finding("rust.no_unwrap", "src/lib.rs", 42, ".unwrap()");
302+
f1.message = "First message".to_string();
303+
f2.message = "Completely different message".to_string();
304+
305+
assert_eq!(compute_fingerprint(&f1), compute_fingerprint(&f2));
306+
}
307+
308+
#[test]
309+
fn test_fingerprint_different_snippet_same_fingerprint() {
310+
// Snippet is NOT part of the fingerprint
311+
let mut f1 = make_finding("rust.no_unwrap", "src/lib.rs", 42, ".unwrap()");
312+
let mut f2 = make_finding("rust.no_unwrap", "src/lib.rs", 42, ".unwrap()");
313+
f1.snippet = "let x = foo.unwrap();".to_string();
314+
f2.snippet = "let y = bar.expect();".to_string();
315+
316+
assert_eq!(compute_fingerprint(&f1), compute_fingerprint(&f2));
317+
}
318+
319+
#[test]
320+
fn test_fingerprint_different_severity_same_fingerprint() {
321+
// Severity is NOT part of the fingerprint
322+
let mut f1 = make_finding("rust.no_unwrap", "src/lib.rs", 42, ".unwrap()");
323+
let mut f2 = make_finding("rust.no_unwrap", "src/lib.rs", 42, ".unwrap()");
324+
f1.severity = Severity::Error;
325+
f2.severity = Severity::Warn;
326+
327+
assert_eq!(compute_fingerprint(&f1), compute_fingerprint(&f2));
328+
}
329+
330+
// =============================================================================
331+
// Consistency: compute_fingerprint uses compute_fingerprint_raw internally
332+
// =============================================================================
333+
334+
#[test]
335+
fn test_compute_fingerprint_matches_raw_format() {
336+
let finding = make_finding("rust.no_unwrap", "src/lib.rs", 42, ".unwrap()");
337+
let fp = compute_fingerprint(&finding);
338+
339+
// Manually compute what the raw input would be
340+
let expected_raw_input = format!(
341+
"{}:{}:{}:{}",
342+
finding.rule_id, finding.path, finding.line, finding.match_text
343+
);
344+
let fp_raw = compute_fingerprint_raw(&expected_raw_input);
345+
346+
assert_eq!(fp, fp_raw);
347+
}
348+
349+
// =============================================================================
350+
// Hex output validation
351+
// =============================================================================
352+
353+
#[test]
354+
fn test_fingerprint_raw_output_is_valid_hex() {
355+
let fp = compute_fingerprint_raw("test");
356+
// SHA-256 hex output should be valid hexadecimal characters
357+
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
358+
// Should be exactly 64 characters for 32 bytes
359+
assert_eq!(fp.len(), 64);
360+
}
361+
362+
#[test]
363+
fn test_fingerprint_all_zero_input_produces_valid_fingerprint() {
364+
// SHA-256 of empty string produces known output
365+
let fp = compute_fingerprint_raw("");
366+
// This is the SHA-256 hash of empty string - verify it's valid hex
367+
assert_eq!(
368+
fp,
369+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
370+
);
371+
}
372+
373+
#[test]
374+
fn test_fingerprint_known_input_produces_known_output() {
375+
// SHA-256 of "hello" is known
376+
let fp = compute_fingerprint_raw("hello");
377+
assert_eq!(fp.len(), 64);
378+
// Verify it's the correct SHA-256 hash of "hello"
379+
assert_eq!(
380+
fp,
381+
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
382+
);
383+
}

0 commit comments

Comments
 (0)