Skip to main content

grit_lib/
ident_resolve.rs

1//! Git-compatible author/committer identity resolution (see upstream `ident.c`).
2//!
3//! This module keeps environment access injectable so callers can test identity resolution
4//! without mutating process-wide state.
5
6use std::ffi::OsString;
7
8use thiserror::Error;
9
10#[cfg(unix)]
11use crate::commit_encoding::decode_bytes;
12use crate::config::ConfigSet;
13use crate::ident_config::ident_default_name;
14
15/// Environment access used for identity resolution.
16pub trait IdentityEnv {
17    /// Return a UTF-8 environment variable, if it exists and is valid Unicode.
18    fn var(&self, key: &str) -> Option<String>;
19
20    /// Return a raw environment variable value, preserving non-UTF-8 bytes on Unix.
21    fn var_os(&self, key: &str) -> Option<OsString>;
22}
23
24/// Environment provider backed by the current process environment.
25#[derive(Clone, Copy, Debug, Default)]
26pub struct SystemIdentityEnv;
27
28impl IdentityEnv for SystemIdentityEnv {
29    fn var(&self, key: &str) -> Option<String> {
30        std::env::var(key).ok()
31    }
32
33    fn var_os(&self, key: &str) -> Option<OsString> {
34        std::env::var_os(key)
35    }
36}
37
38/// Whether `GIT_AUTHOR_NAME` / `GIT_COMMITTER_NAME` is unset vs set (possibly empty).
39///
40/// Git treats a set-but-empty value as an explicit override: it must not fall through
41/// to `user.name` or passwd/GECOS fallback (`t7518-ident-corner-cases`).
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub enum GitIdentityNameEnv {
44    /// Variable is not present in the environment.
45    Unset,
46    /// Present after trimming whitespace (may be `""`).
47    Set(String),
48}
49
50/// Author vs committer for `GIT_*` / `author.*` / `committer.*` lookup.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum IdentRole {
53    /// Author identity.
54    Author,
55    /// Committer identity.
56    Committer,
57}
58
59impl IdentRole {
60    fn env_name_key(self) -> &'static str {
61        match self {
62            IdentRole::Author => "GIT_AUTHOR_NAME",
63            IdentRole::Committer => "GIT_COMMITTER_NAME",
64        }
65    }
66
67    fn env_email_key(self) -> &'static str {
68        match self {
69            IdentRole::Author => "GIT_AUTHOR_EMAIL",
70            IdentRole::Committer => "GIT_COMMITTER_EMAIL",
71        }
72    }
73
74    fn config_name_key(self) -> &'static str {
75        match self {
76            IdentRole::Author => "author.name",
77            IdentRole::Committer => "committer.name",
78        }
79    }
80
81    fn config_email_key(self) -> &'static str {
82        match self {
83            IdentRole::Author => "author.email",
84            IdentRole::Committer => "committer.email",
85        }
86    }
87
88    /// Heading Git prints before identity setup advice.
89    #[must_use]
90    pub fn missing_email_hint(self) -> &'static str {
91        match self {
92            IdentRole::Author => "Author identity unknown",
93            IdentRole::Committer => "Committer identity unknown",
94        }
95    }
96}
97
98/// Errors returned by strict identity resolution.
99#[derive(Clone, Debug, Error, PartialEq, Eq)]
100pub enum IdentityError {
101    /// `user.useConfigOnly` disables email auto-detection and no config email was provided.
102    ///
103    /// The Display text here is a terse, functional description of the
104    /// condition. The user-facing setup guidance is rendered by the
105    /// (GPL-licensed) CLI layer, which maps this variant to its own message.
106    #[error("email auto-detection is disabled (user.useConfigOnly) and no configured email is available")]
107    AutoDetectionDisabled {
108        /// Identity role being resolved.
109        role: IdentRole,
110    },
111    /// Git rejects empty ident names.
112    #[error("empty ident name (for <{email}>) not allowed")]
113    EmptyName {
114        /// Email address associated with the attempted identity.
115        email: String,
116        /// Identity role being resolved.
117        role: IdentRole,
118    },
119    /// Git rejects names containing only "crud" characters.
120    #[error("invalid ident name: '{name}'")]
121    InvalidName {
122        /// Rejected name.
123        name: String,
124    },
125}
126
127/// Read a `GIT_*_NAME` variable like Git's `getenv`: unset vs set, preserving explicit empty.
128#[must_use]
129pub fn read_git_identity_name_env_with<E: IdentityEnv>(env: &E, key: &str) -> GitIdentityNameEnv {
130    let Some(os) = env.var_os(key) else {
131        return GitIdentityNameEnv::Unset;
132    };
133    #[cfg(unix)]
134    {
135        use std::os::unix::ffi::OsStrExt;
136        let bytes = os.as_bytes();
137        let s = if std::str::from_utf8(bytes).is_ok() {
138            String::from_utf8_lossy(bytes).into_owned()
139        } else {
140            decode_bytes(Some("ISO8859-1"), bytes)
141        };
142        GitIdentityNameEnv::Set(s.trim().to_owned())
143    }
144    #[cfg(not(unix))]
145    {
146        let s = os.to_str().map(|t| t.trim().to_owned()).unwrap_or_default();
147        GitIdentityNameEnv::Set(s)
148    }
149}
150
151/// Read `GIT_AUTHOR_NAME` / `GIT_COMMITTER_NAME` from the supplied environment.
152///
153/// Returns [`None`] when the variable is unset or set to whitespace only. A set-but-empty
154/// value (after trim) is still [`None`] here; use [`read_git_identity_name_env_with`] when the
155/// distinction matters.
156#[must_use]
157pub fn read_git_identity_name_from_env_with<E: IdentityEnv>(env: &E, key: &str) -> Option<String> {
158    match read_git_identity_name_env_with(env, key) {
159        GitIdentityNameEnv::Unset => None,
160        GitIdentityNameEnv::Set(s) if s.is_empty() => None,
161        GitIdentityNameEnv::Set(s) => Some(s),
162    }
163}
164
165fn use_config_only(config: &ConfigSet) -> bool {
166    match config.get_bool("user.useConfigOnly") {
167        Some(Ok(b)) => b,
168        Some(Err(_)) => false,
169        None => false,
170    }
171}
172
173fn config_mail_given(config: &ConfigSet) -> bool {
174    ["user.email", "author.email", "committer.email"]
175        .iter()
176        .any(|key| config.get(key).is_some_and(|v| !v.trim().is_empty()))
177}
178
179fn ident_name_has_non_crud(name: &str) -> bool {
180    name.chars().any(|c| {
181        let o = c as u32;
182        !(o <= 32
183            || c == ','
184            || c == ':'
185            || c == ';'
186            || c == '<'
187            || c == '>'
188            || c == '"'
189            || c == '\\'
190            || c == '\'')
191    })
192}
193
194fn synthetic_email_with<E: IdentityEnv>(env: &E) -> String {
195    let user = env
196        .var("USER")
197        .or_else(|| env.var("USERNAME"))
198        .unwrap_or_else(|| "unknown".to_owned());
199    let host = env.var("HOSTNAME").unwrap_or_else(|| "unknown".to_owned());
200    let domain = if host.contains('.') {
201        host
202    } else {
203        format!("{host}.(none)")
204    };
205    format!("{user}@{domain}")
206}
207
208fn resolve_email_inner_with<E: IdentityEnv>(
209    env: &E,
210    config: &ConfigSet,
211    role: IdentRole,
212    honor_use_config_only: bool,
213) -> Result<String, IdentityError> {
214    if let Some(v) = env.var(role.env_email_key()) {
215        let t = v.trim();
216        if !t.is_empty() {
217            return Ok(t.to_owned());
218        }
219    }
220
221    if let Some(v) = config.get(role.config_email_key()) {
222        let t = v.trim();
223        if !t.is_empty() {
224            return Ok(t.to_owned());
225        }
226    }
227
228    if let Some(v) = config.get("user.email") {
229        let t = v.trim();
230        if !t.is_empty() {
231            return Ok(t.to_owned());
232        }
233    }
234
235    if honor_use_config_only && use_config_only(config) && !config_mail_given(config) {
236        return Err(IdentityError::AutoDetectionDisabled { role });
237    }
238
239    if let Some(v) = env.var("EMAIL") {
240        let t = v.trim();
241        if !t.is_empty() {
242            return Ok(t.to_owned());
243        }
244    }
245
246    Ok(synthetic_email_with(env))
247}
248
249/// Resolve email for a role when creating commits (honors `user.useConfigOnly`).
250pub fn resolve_email_with<E: IdentityEnv>(
251    env: &E,
252    config: &ConfigSet,
253    role: IdentRole,
254) -> Result<String, IdentityError> {
255    resolve_email_inner_with(env, config, role, true)
256}
257
258/// Resolve email without failing on `user.useConfigOnly` (e.g. `git var -l`, reflog-style).
259#[must_use]
260pub fn resolve_email_lenient_with<E: IdentityEnv>(
261    env: &E,
262    config: &ConfigSet,
263    role: IdentRole,
264) -> String {
265    resolve_email_inner_with(env, config, role, false).unwrap_or_else(|_| synthetic_email_with(env))
266}
267
268/// Name from env and config without erroring (for `git var -l`).
269#[must_use]
270pub fn peek_name_with<E: IdentityEnv>(
271    env: &E,
272    config: &ConfigSet,
273    role: IdentRole,
274) -> Option<String> {
275    match read_git_identity_name_env_with(env, role.env_name_key()) {
276        GitIdentityNameEnv::Set(s) => {
277            if s.is_empty() {
278                None
279            } else {
280                Some(s)
281            }
282        }
283        GitIdentityNameEnv::Unset => {
284            if let Some(v) = config.get(role.config_name_key()) {
285                let t = v.trim();
286                if !t.is_empty() {
287                    return Some(t.to_owned());
288                }
289            }
290            let d = ident_default_name(config);
291            if d.is_empty() {
292                None
293            } else {
294                Some(d)
295            }
296        }
297    }
298}
299
300/// Resolve name for a role when creating commits.
301pub fn resolve_name_with<E: IdentityEnv>(
302    env: &E,
303    config: &ConfigSet,
304    role: IdentRole,
305) -> Result<String, IdentityError> {
306    let email = resolve_email_inner_with(env, config, role, true)?;
307
308    let name: String = match read_git_identity_name_env_with(env, role.env_name_key()) {
309        GitIdentityNameEnv::Set(s) => s,
310        GitIdentityNameEnv::Unset => {
311            if let Some(v) = config.get(role.config_name_key()) {
312                let t = v.trim();
313                if !t.is_empty() {
314                    t.to_owned()
315                } else {
316                    ident_default_name(config)
317                }
318            } else {
319                ident_default_name(config)
320            }
321        }
322    };
323
324    if name.is_empty() {
325        return Err(IdentityError::EmptyName { email, role });
326    }
327
328    if !ident_name_has_non_crud(&name) {
329        return Err(IdentityError::InvalidName { name });
330    }
331
332    Ok(name)
333}
334
335/// Committer name/email for reflog and other non-strict contexts: never errors; always has an email.
336#[must_use]
337pub fn resolve_loose_committer_parts_with<E: IdentityEnv>(
338    env: &E,
339    config: &ConfigSet,
340) -> (String, String) {
341    let name = match read_git_identity_name_env_with(env, "GIT_COMMITTER_NAME") {
342        GitIdentityNameEnv::Set(s) => {
343            if s.is_empty() {
344                None
345            } else {
346                Some(s)
347            }
348        }
349        GitIdentityNameEnv::Unset => read_git_identity_name_from_env_with(env, "GIT_AUTHOR_NAME"),
350    }
351    .or_else(|| {
352        config
353            .get("committer.name")
354            .map(|s| s.trim().to_owned())
355            .filter(|s| !s.is_empty())
356    })
357    .or_else(|| {
358        config
359            .get("user.name")
360            .map(|s| s.trim().to_owned())
361            .filter(|s| !s.is_empty())
362    })
363    .or_else(|| {
364        let d = ident_default_name(config);
365        if d.is_empty() {
366            None
367        } else {
368            Some(d)
369        }
370    })
371    .unwrap_or_else(|| "Unknown".to_owned());
372
373    let email = env
374        .var("GIT_COMMITTER_EMAIL")
375        .map(|s| s.trim().to_owned())
376        .filter(|s| !s.is_empty())
377        .or_else(|| {
378            env.var("GIT_AUTHOR_EMAIL")
379                .map(|s| s.trim().to_owned())
380                .filter(|s| !s.is_empty())
381        })
382        .or_else(|| {
383            config
384                .get("committer.email")
385                .map(|s| s.trim().to_owned())
386                .filter(|s| !s.is_empty())
387        })
388        .or_else(|| {
389            config
390                .get("user.email")
391                .map(|s| s.trim().to_owned())
392                .filter(|s| !s.is_empty())
393        })
394        .or_else(|| {
395            env.var("EMAIL")
396                .map(|s| s.trim().to_owned())
397                .filter(|s| !s.is_empty())
398        })
399        .unwrap_or_else(|| synthetic_email_with(env));
400
401    (name, email)
402}