Skip to main content

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;