big_code_analysis/vcs/identity.rs
1//! Canonical author identity and bot-author detection.
2//!
3//! Backend-agnostic: the git backend resolves a commit signature
4//! through `.mailmap` and hands the canonical `(name, email)` byte
5//! pairs here. The email (lowercased) is the identity key — two commits
6//! with the same canonical email count as one author even under
7//! differing display names. Raw identities never leave the process;
8//! `--emit-author-details` opts into a SHA-256 hash of the canonical
9//! email instead of the plaintext.
10//!
11//! The emitted hash is a *stable pseudonym*, not anonymization. It
12//! avoids putting plaintext emails in output/caches and deters casual
13//! disclosure, but it is **not** cryptographically irreversible: an
14//! email is low-entropy and enumerable, so an attacker holding a
15//! candidate set of emails (commit histories are public) can recover the
16//! mapping by hashing each candidate or via a precomputed email→hash
17//! table — the same weakness that broke Gravatar's email hashing. See
18//! [`AuthorId::hashed`] for the threat model.
19//!
20//! Users who need stronger resistance can opt into keyed hashing with a
21//! secret [`AuthorHashKey`] (`--author-hash-key`, issue #956): the emitted
22//! digest becomes an HMAC the attacker cannot reproduce without the key.
23//! See [`AuthorId::emit_hashed`].
24
25// `KeyInit` carries `new_from_slice`. It was reachable through `Mac`
26// under hmac 0.12 / digest 0.10; the 0.13 / 0.11 trait split makes it a
27// separate import.
28use hmac::{Hmac, KeyInit, Mac};
29use sha2::{Digest, Sha256};
30
31use regex::Regex;
32
33use super::error::Error;
34
35/// A canonical author identity, keyed by lowercased email.
36///
37/// Falls back to the lowercased display name when the email is empty
38/// (some imported histories carry name-only authors). Compared and
39/// hashed by that key so author *counts* and *ownership* are stable
40/// across display-name variation.
41///
42/// The key is normally the plaintext canonical email; a [`from_digest`]
43/// identity instead holds the SHA-256 digest, used when an identity is
44/// reconstructed from the persistent VCS cache (issue #334), which never
45/// stores plaintext author keys on disk. Both forms share the same
46/// equality/hashing contract — distinct-author *counts* and *ownership*
47/// ratios are preserved either way because the digest is collision-free
48/// over the author set in practice (SHA-256 of distinct emails yields
49/// distinct hashes) — and within any one walk every identity is of the
50/// same form, so the `is_digest` flag never makes two
51/// keys-for-the-same-person compare unequal.
52///
53/// [`from_digest`]: AuthorId::from_digest
54#[derive(Clone, Debug, PartialEq, Eq, Hash)]
55pub struct AuthorId {
56 /// The canonical key: the lowercased email/name, or — for a
57 /// [`from_digest`](AuthorId::from_digest) identity — its SHA-256 hex.
58 key: String,
59 /// `true` when `key` already holds the SHA-256 digest, so
60 /// [`hashed`](AuthorId::hashed) returns it verbatim rather than
61 /// hashing a second time.
62 is_digest: bool,
63}
64
65impl AuthorId {
66 /// Build a canonical identity from raw signature bytes.
67 ///
68 /// Bytes are interpreted lossily as UTF-8: an identity is a map key
69 /// and a hash pre-image, never re-emitted as a path, so a stray
70 /// non-UTF-8 byte degrading to U+FFFD is acceptable and keeps the
71 /// function total.
72 #[must_use]
73 pub fn new(name: &[u8], email: &[u8]) -> Self {
74 let email_key = String::from_utf8_lossy(email).trim().to_lowercase();
75 let key = if email_key.is_empty() {
76 String::from_utf8_lossy(name).trim().to_lowercase()
77 } else {
78 email_key
79 };
80 Self {
81 key,
82 is_digest: false,
83 }
84 }
85
86 /// Whether this identity carries a usable key.
87 ///
88 /// An author with neither a name nor an email trims to the empty key,
89 /// which would otherwise collapse every keyless author into one
90 /// phantom identity (the same `Eq`/`Hash`). Callers building a
91 /// participant set drop keyless identities so they never anchor
92 /// ownership or inflate edit counts (issue #817). A
93 /// [`from_digest`](AuthorId::from_digest) identity is never keyless
94 /// (a SHA-256 hex is non-empty).
95 #[must_use]
96 pub fn has_identity(&self) -> bool {
97 !self.key.is_empty()
98 }
99
100 /// Reconstruct an identity from a previously-emitted SHA-256 [`hashed`]
101 /// digest. The persistent VCS cache stores authors in this hashed form
102 /// (never plaintext — see [`hashed`] for what that does and does not
103 /// protect), and replaying it must reproduce the same author counts,
104 /// ownership, and emitted hashes as a fresh walk — so a `from_digest`
105 /// identity hashes to itself.
106 ///
107 /// [`hashed`]: AuthorId::hashed
108 #[must_use]
109 pub fn from_digest(digest: String) -> Self {
110 Self {
111 key: digest,
112 is_digest: true,
113 }
114 }
115
116 /// SHA-256 hex digest of the canonical key, for
117 /// `--emit-author-details`. Stable across runs, so the same author
118 /// carries the same pseudonym in every report and survives a cache
119 /// round-trip.
120 ///
121 /// # Privacy: pseudonym, not anonymization
122 ///
123 /// The digest avoids emitting the plaintext email and deters *casual*
124 /// disclosure, but it is **not** cryptographically irreversible. The
125 /// pre-image is an email — low-entropy and enumerable — and commit
126 /// histories are public, so an attacker with a candidate set of emails
127 /// can recover the mapping by hashing each candidate or with a
128 /// precomputed email→hash table (the Gravatar weakness). Treat
129 /// published digests as pseudonymization that keeps plaintext emails
130 /// out of output and caches, **not** as robust anonymization against a
131 /// determined attacker. Hardening (a keyed HMAC / slow KDF) is tracked
132 /// as a follow-up; it must be reconciled with the issue-#334
133 /// cache-replay invariant that replaying reproduces identical digests.
134 ///
135 /// A [`from_digest`](AuthorId::from_digest) identity already *is* the
136 /// digest, so it is returned unchanged (re-hashing would double-hash
137 /// and diverge from a fresh walk).
138 #[must_use]
139 pub fn hashed(&self) -> String {
140 if self.is_digest {
141 return self.key.clone();
142 }
143 let mut hasher = Sha256::new();
144 hasher.update(self.key.as_bytes());
145 to_hex(&hasher.finalize())
146 }
147
148 /// The author digest emitted for `--emit-author-details`, optionally
149 /// hardened with a caller-supplied [`AuthorHashKey`].
150 ///
151 /// Without a key this is exactly [`hashed`](Self::hashed) — the bare
152 /// SHA-256 — so default output is unchanged. With a key it is
153 /// `HMAC-SHA256(key, hashed_hex)`: an attacker holding a candidate set
154 /// of emails can no longer recover the mapping by hashing each
155 /// candidate, nor with a precomputed email→hash table (the Gravatar
156 /// weakness [`hashed`](Self::hashed) documents), because computing the
157 /// digest for any candidate now requires the secret key they do not
158 /// hold.
159 ///
160 /// Keying the *inner* digest rather than the raw email is what
161 /// preserves the issue-#334 cache-replay invariant: the persistent
162 /// cache stores the unkeyed inner SHA-256 (a
163 /// [`from_digest`](Self::from_digest) identity *is* that digest), so
164 /// replaying a cached walk under any key reproduces the same emitted
165 /// value as a fresh walk, and the same cached walk can be re-finalized
166 /// under a different key without re-walking. The trade-off is that the
167 /// on-disk cache still holds the *unkeyed* digest; it is local-only and
168 /// never published, matching the cache's existing threat model.
169 #[must_use]
170 pub fn emit_hashed(&self, key: Option<&AuthorHashKey>) -> String {
171 let base = self.hashed();
172 match key {
173 None => base,
174 Some(key) => key.apply(&base),
175 }
176 }
177}
178
179/// A secret key for the opt-in keyed author-identity hashing
180/// (`--author-hash-key`, issue #956).
181///
182/// A newtype for two reasons: it keeps the key material out of any
183/// derived `Debug` (the impl below redacts it, so the secret never lands
184/// in [`Options`](super::options::Options)' debug output or a log), and it
185/// gives the non-empty validation one enforced home — an
186/// [`AuthorHashKey`] cannot hold a zero-length key.
187#[derive(Clone)]
188pub struct AuthorHashKey {
189 key: Vec<u8>,
190}
191
192impl AuthorHashKey {
193 /// Build a key from raw bytes.
194 ///
195 /// # Errors
196 ///
197 /// Returns [`Error::InvalidAuthorHashKey`] when `key` is empty — an
198 /// empty key provides no protection and is always a user mistake
199 /// (e.g. an unset environment variable expanding to `""`).
200 pub fn new(key: Vec<u8>) -> Result<Self, Error> {
201 if key.is_empty() {
202 return Err(Error::InvalidAuthorHashKey("the key is empty".to_owned()));
203 }
204 Ok(Self { key })
205 }
206
207 /// Harden an already-computed unkeyed hex digest into its keyed
208 /// `HMAC-SHA256(key, digest_hex)` form. Shared by
209 /// [`AuthorId::emit_hashed`] and the bus-factor key-author list, which
210 /// holds the unkeyed digest for deterministic tie-breaking and only
211 /// hardens it on the way out.
212 #[must_use]
213 pub(crate) fn apply(&self, digest_hex: &str) -> String {
214 // `new_from_slice` accepts a key of any length (HMAC pads or hashes
215 // it per RFC 2104), so it is infallible here; the `expect`
216 // documents that provably-unreachable invariant (AGENTS.md permits
217 // it for such cases).
218 let mut mac =
219 Hmac::<Sha256>::new_from_slice(&self.key).expect("HMAC accepts a key of any length");
220 mac.update(digest_hex.as_bytes());
221 to_hex(&mac.finalize().into_bytes())
222 }
223}
224
225impl std::fmt::Debug for AuthorHashKey {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 // Never render the secret material — report only that a key is set,
228 // so it cannot leak through `Options`' derived `Debug`.
229 f.debug_struct("AuthorHashKey").finish_non_exhaustive()
230 }
231}
232
233/// Lowercase-hex encode bytes. Shared by [`AuthorId::hashed`] and the
234/// keyed [`AuthorHashKey::apply`] so both render digests identically.
235fn to_hex(bytes: &[u8]) -> String {
236 let mut hex = String::with_capacity(bytes.len() * 2);
237 for byte in bytes {
238 use std::fmt::Write as _;
239 // Writing to a String is infallible; the formatter never errors,
240 // so the result is discarded deliberately.
241 let _ = write!(hex, "{byte:02x}");
242 }
243 hex
244}
245
246/// Matches author identities against a bot-exclusion pattern.
247#[derive(Clone, Debug)]
248pub struct BotFilter {
249 pattern: Regex,
250}
251
252impl BotFilter {
253 /// Compile a bot-exclusion pattern.
254 ///
255 /// # Errors
256 ///
257 /// Returns [`Error::InvalidBotPattern`] when `pattern` is not a
258 /// valid regular expression.
259 pub fn new(pattern: &str) -> Result<Self, Error> {
260 let pattern = Regex::new(pattern).map_err(|e| Error::InvalidBotPattern(e.to_string()))?;
261 Ok(Self { pattern })
262 }
263
264 /// Returns `true` when either the display name or the email matches
265 /// the bot pattern. Both are checked because automation identities
266 /// vary on which field carries the `[bot]` marker.
267 #[must_use]
268 pub fn is_bot(&self, name: &[u8], email: &[u8]) -> bool {
269 let name = String::from_utf8_lossy(name);
270 let email = String::from_utf8_lossy(email);
271 self.pattern.is_match(&name) || self.pattern.is_match(&email)
272 }
273}
274
275#[cfg(test)]
276#[path = "identity_tests.rs"]
277mod tests;