1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
use super::*;
use crate::vcs::options::DEFAULT_BOT_PATTERN;
#[test]
fn same_email_different_name_is_one_identity() {
let a = AuthorId::new(b"Ada Lovelace", b"ada@example.com");
let b = AuthorId::new(b"A. Lovelace", b"ada@example.com");
assert_eq!(a, b);
}
#[test]
fn email_is_case_insensitive() {
let a = AuthorId::new(b"Ada", b"Ada@Example.COM");
let b = AuthorId::new(b"Ada", b"ada@example.com");
assert_eq!(a, b);
}
#[test]
fn distinct_emails_are_distinct_identities() {
let a = AuthorId::new(b"Ada", b"ada@example.com");
let b = AuthorId::new(b"Grace", b"grace@example.com");
assert_ne!(a, b);
}
#[test]
fn empty_email_falls_back_to_name() {
let a = AuthorId::new(b"Ada Lovelace", b"");
let b = AuthorId::new(b"ada lovelace", b"");
assert_eq!(a, b);
// ...and is distinct from an email-keyed identity.
assert_ne!(a, AuthorId::new(b"Ada", b"ada@example.com"));
}
#[test]
fn has_identity_is_false_only_for_empty_key() {
// Issue #817: an author with neither name nor email trims to the empty
// key and must be reported as keyless so callers can drop it instead
// of collapsing every such author into one phantom identity.
assert!(!AuthorId::new(b"", b"").has_identity());
assert!(!AuthorId::new(b" ", b" ").has_identity());
// A name-only author (imported histories) still carries a key.
assert!(AuthorId::new(b"Ada Lovelace", b"").has_identity());
// An email-only author carries a key.
assert!(AuthorId::new(b"", b"ada@example.com").has_identity());
// A digest identity is never keyless.
assert!(AuthorId::from_digest("deadbeef".to_string()).has_identity());
}
#[test]
fn hashed_is_stable_and_avoids_plaintext_email() {
// The digest is a stable pseudonym that keeps the plaintext email out
// of output (it is NOT cryptographically irreversible — see #811 and
// `AuthorId::hashed`'s privacy note). This pins what it actually
// provides: stability, hex shape, and that the output is not the
// plaintext email.
let email = "ada@example.com";
let id = AuthorId::new(b"Ada", email.as_bytes());
let h1 = id.hashed();
let h2 = AuthorId::new(b"different name", email.as_bytes()).hashed();
// Hash keys off the canonical email, so the same email hashes equal
// regardless of display name.
assert_eq!(h1, h2);
// SHA-256 hex is 64 chars.
assert_eq!(h1.len(), 64);
// The output is the digest, never the plaintext email itself.
assert_ne!(h1, email);
assert!(!h1.contains(email));
// The digest depends on the canonical email: a different email
// hashes differently (so the hash is not a constant, and pins that
// the email — not the name — is the pre-image).
assert_ne!(h1, AuthorId::new(b"Ada", b"grace@example.com").hashed());
}
#[test]
fn from_digest_hashes_to_itself_and_preserves_identity() {
// The persistent cache stores `hashed()` digests, never plaintext;
// replaying must reproduce a fresh walk's output exactly. A
// reconstructed identity must therefore hash back to the same digest
// (no double-hashing) and keep equality/ownership intact.
let original = AuthorId::new(b"Ada", b"ada@example.com");
let digest = original.hashed();
let restored = AuthorId::from_digest(digest.clone());
assert_eq!(restored.hashed(), digest);
// Two reconstructions of the same digest are one identity (so author
// counts and ownership ratios survive a cache round-trip).
assert_eq!(restored, AuthorId::from_digest(digest));
// Distinct people stay distinct after reconstruction.
let other = AuthorId::new(b"Grace", b"grace@example.com").hashed();
assert_ne!(restored, AuthorId::from_digest(other));
}
#[test]
fn empty_author_hash_key_is_rejected() {
// An empty key provides no protection and is always a user mistake
// (e.g. an unset environment variable expanding to ""); reject it loudly.
assert!(matches!(
AuthorHashKey::new(Vec::new()),
Err(Error::InvalidAuthorHashKey(_))
));
// A non-empty key is accepted.
assert!(AuthorHashKey::new(b"secret".to_vec()).is_ok());
}
#[test]
fn author_hash_key_debug_redacts_the_secret() {
// The key must never leak through `Options`' derived `Debug`; its own
// `Debug` reports only that a key is set, never the bytes.
let key = AuthorHashKey::new(b"super-secret-key".to_vec()).expect("non-empty");
let rendered = format!("{key:?}");
// Pin the *exact* redacted form. A `#[derive(Debug)]` would instead
// render `AuthorHashKey { key: [115, 117, ...] }` — note it leaks the
// bytes as decimals, not as the ASCII string, so a plain
// `!contains("super-secret-key")` check would pass against the leaky
// derive. Exact equality is what actually catches that regression.
assert_eq!(rendered, "AuthorHashKey { .. }");
assert!(!rendered.contains("super-secret-key"));
}
#[test]
fn emit_hashed_without_key_is_the_bare_digest() {
// Default output (no key) is exactly `hashed()` — #956 must not change
// the unkeyed emission, for SemVer and cache compatibility.
let id = AuthorId::new(b"Ada", b"ada@example.com");
assert_eq!(id.emit_hashed(None), id.hashed());
}
#[test]
fn emit_hashed_with_key_hardens_and_is_deterministic() {
let email = "ada@example.com";
let id = AuthorId::new(b"Ada", email.as_bytes());
let key = AuthorHashKey::new(b"team-secret".to_vec()).expect("non-empty");
let keyed = id.emit_hashed(Some(&key));
// Still a 64-char SHA-256-width hex digest, never the plaintext email.
assert_eq!(keyed.len(), 64);
assert!(keyed.chars().all(|c| c.is_ascii_hexdigit()));
assert!(!keyed.contains(email));
// The key changes the emission: an attacker without the key sees a
// value unrelated to the bare SHA-256 they could precompute.
assert_ne!(keyed, id.hashed());
// Stable across runs/reports for a fixed key (the property #334 and
// cross-report pseudonyms rely on).
assert_eq!(keyed, id.emit_hashed(Some(&key)));
// A different key yields a different digest for the same author.
let other_key = AuthorHashKey::new(b"different-secret".to_vec()).expect("non-empty");
assert_ne!(keyed, id.emit_hashed(Some(&other_key)));
// Distinct authors stay distinct under the same key.
let bob = AuthorId::new(b"Bob", b"bob@example.com");
assert_ne!(keyed, bob.emit_hashed(Some(&key)));
}
#[test]
fn keyed_emit_survives_a_cache_round_trip() {
// The crux of the #334 reconciliation: the cache stores the *unkeyed*
// inner digest, and the key is applied at finalization. So a fresh
// identity and one reconstructed from its cached digest must emit the
// SAME keyed value — otherwise replaying a cached walk under a key
// would diverge from a fresh walk.
let key = AuthorHashKey::new(b"team-secret".to_vec()).expect("non-empty");
let fresh = AuthorId::new(b"Ada", b"ada@example.com");
let restored = AuthorId::from_digest(fresh.hashed());
assert_eq!(
fresh.emit_hashed(Some(&key)),
restored.emit_hashed(Some(&key))
);
// And the unkeyed round-trip is unchanged too.
assert_eq!(fresh.emit_hashed(None), restored.emit_hashed(None));
}
#[test]
fn default_bot_pattern_matches_known_bots() {
let filter = BotFilter::new(DEFAULT_BOT_PATTERN).expect("default pattern compiles");
assert!(filter.is_bot(
b"dependabot[bot]",
b"49699333+dependabot[bot]@users.noreply.github.com"
));
assert!(filter.is_bot(b"renovate[bot]", b"renovate@whitesourcesoftware.com"));
assert!(filter.is_bot(b"github-actions[bot]", b""));
// A human is not a bot.
assert!(!filter.is_bot(b"Ada Lovelace", b"ada@example.com"));
}
#[test]
fn invalid_bot_pattern_is_rejected() {
assert!(matches!(
BotFilter::new("(unclosed"),
Err(Error::InvalidBotPattern(_))
));
}