Skip to main content

sley_object/
identity.rs

1//! Author/committer identity resolution from env and config (git's `ident.c`).
2//!
3//! Sunk out of the CLI so every engine path that authors objects resolves
4//! identities through the same precedence chain:
5//!
6//! 1. `GIT_{role}_NAME`/`GIT_{role}_EMAIL` env vars
7//! 2. `-c {author,committer}.name=` / `GIT_CONFIG_*` command-line overrides
8//! 3. effective config `{author,committer}.name/email`
9//! 4. effective config `user.name/email`
10//! 5. sley's built-in default identity
11
12use std::env;
13use std::ffi::OsString;
14
15use sley_config::GitConfig;
16use sley_core::date::approxidate::parse_commit_date;
17use sley_core::{GitError, Result};
18
19/// Canonicalise a `GIT_*_DATE`/`--date=` value to git's raw `<seconds> +HHMM`
20/// form so the sequencer's identity builder (which only accepts the raw form)
21/// stores the same bytes git would.
22///
23/// git's `commit-tree` / `commit` run author and committer dates through
24/// `parse_date` / `approxidate_careful`, accepting ISO-8601
25/// (`2005-04-07T22:13:13`), `<date> <time> <tz>`, RFC-2822, fuzzy approxidates,
26/// and the raw form. Values that do not parse are passed through verbatim so
27/// callers that only need best-effort conversion (env `GIT_*_DATE`) still get a
28/// diagnostic from the identity formatter; prefer [`try_canonicalize_commit_date`]
29/// when a hard reject with git's `invalid date format` message is required
30/// (`--date=`).
31pub fn canonicalize_commit_date(date: &str) -> String {
32    if date.is_empty() {
33        return default_commit_date();
34    }
35    match parse_commit_date(date) {
36        Some((seconds, tz)) => format!("{seconds} {tz}"),
37        None => date.to_string(),
38    }
39}
40
41/// Like [`canonicalize_commit_date`] but returns `None` when the value does not
42/// parse — used for `git commit --date=` so we can die with
43/// `fatal: invalid date format: …` matching git's `parse_force_date`.
44pub fn try_canonicalize_commit_date(date: &str) -> Option<String> {
45    if date.is_empty() {
46        return Some(default_commit_date());
47    }
48    parse_commit_date(date).map(|(seconds, tz)| format!("{seconds} {tz}"))
49}
50
51pub fn default_commit_date() -> String {
52    let seconds = std::time::SystemTime::now()
53        .duration_since(std::time::UNIX_EPOCH)
54        .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64)
55        .unwrap_or(0);
56    format!("{seconds} +0000")
57}
58
59/// Format a name/email/date triple as git's raw ident line
60/// (`Name <email> <seconds> +HHMM`), rejecting control bytes in either
61/// component and anything but the raw date form.
62pub fn format_commit_identity(name: &str, email: &str, date: &str) -> Result<Vec<u8>> {
63    format_commit_identity_bytes(name.as_bytes(), email.as_bytes(), date)
64}
65
66pub fn format_commit_identity_bytes(name: &[u8], email: &[u8], date: &str) -> Result<Vec<u8>> {
67    validate_identity_component_bytes("name", name)?;
68    validate_identity_component_bytes("email", email)?;
69    let (seconds, timezone) = parse_raw_git_date(date)?;
70    let mut out = Vec::with_capacity(name.len() + email.len() + timezone.len() + 32);
71    out.extend_from_slice(name);
72    out.extend_from_slice(b" <");
73    out.extend_from_slice(email);
74    out.extend_from_slice(b"> ");
75    out.extend_from_slice(seconds.to_string().as_bytes());
76    out.push(b' ');
77    out.extend_from_slice(timezone.as_bytes());
78    Ok(out)
79}
80
81fn validate_identity_component_bytes(name: &str, value: &[u8]) -> Result<()> {
82    if value.iter().any(|byte| matches!(*byte, b'\n' | b'\r' | 0)) {
83        return Err(GitError::InvalidFormat(format!(
84            "commit identity {name} contains a control byte"
85        )));
86    }
87    Ok(())
88}
89
90fn parse_raw_git_date(date: &str) -> Result<(i64, String)> {
91    let mut parts = date.split_whitespace();
92    let seconds = parts
93        .next()
94        .ok_or_else(|| GitError::InvalidFormat("missing commit date seconds".into()))?;
95    let timezone = parts
96        .next()
97        .ok_or_else(|| GitError::InvalidFormat("missing commit date timezone".into()))?;
98    if parts.next().is_some() {
99        return Err(GitError::InvalidFormat(
100            "commit date has trailing fields".into(),
101        ));
102    }
103    let seconds = seconds.strip_prefix('@').unwrap_or(seconds);
104    let seconds = seconds
105        .parse::<i64>()
106        .map_err(|_| GitError::InvalidFormat("invalid commit date seconds".into()))?;
107    validate_timezone(timezone)?;
108    Ok((seconds, timezone.to_string()))
109}
110
111fn validate_timezone(timezone: &str) -> Result<()> {
112    let bytes = timezone.as_bytes();
113    if bytes.len() != 5
114        || !matches!(bytes[0], b'+' | b'-')
115        || !bytes[1..].iter().all(u8::is_ascii_digit)
116    {
117        return Err(GitError::InvalidFormat(format!(
118            "invalid commit timezone {timezone}"
119        )));
120    }
121    Ok(())
122}
123
124/// Explicit effective config used as the identity fallback. `Skip` means the
125/// caller already has both fields from the environment, so config lookup is
126/// unnecessary; `Loaded` borrows the invocation's already-resolved snapshot.
127pub enum IdentityConfig<'a> {
128    Skip,
129    Loaded(&'a GitConfig),
130}
131
132/// Look up a single injected (`-c`/`--config-env`/`GIT_CONFIG_COUNT`) override,
133/// mirroring git's highest-precedence command-line layer. Parse failures print
134/// git's two-line diagnostic exactly once per failing lookup; every other miss
135/// is silent.
136fn injected_config_value(key: &str) -> Option<String> {
137    let canonical = match sley_config::canonicalize_config_key(key) {
138        Ok(canonical) => canonical,
139        // The lookup key is a fixed internal key; if it fails to canonicalise
140        // there can be no matching override.
141        Err(_) => return None,
142    };
143    let parameters_env = sley_config::effective_config_parameters_env();
144    match sley_config::injected_config_parameters(parameters_env.as_deref()) {
145        Ok(parameters) => parameters
146            .iter()
147            .rev()
148            .find(|param| param.canonical_key.eq_ignore_ascii_case(&canonical))
149            .map(|param| match &param.value {
150                Some(value) => value.clone(),
151                None => "true".to_string(),
152            }),
153        Err(err) => {
154            sley_core::diagnostic!(Stderr, true, "error: {}", err.message());
155            sley_core::diagnostic!(Stderr, true, "fatal: unable to parse command-line config");
156            None
157        }
158    }
159}
160
161/// Resolve an identity config key (`user.name`/`user.email`) following git's
162/// precedence below the environment: `-c`/`GIT_CONFIG_*` command-line overrides
163/// first, then the effective config (repository, then global, then system).
164pub fn identity_config_value(key: &str, config: &mut IdentityConfig<'_>) -> Option<String> {
165    if let Some(value) = injected_config_value(key) {
166        return Some(value);
167    }
168    let (section, name) = key.split_once('.')?;
169    let loaded = match config {
170        IdentityConfig::Skip => return None,
171        IdentityConfig::Loaded(config) => *config,
172    };
173    loaded.get(section, None, name).map(str::to_string)
174}
175
176pub fn identity_config_value_for_role(
177    role: &str,
178    field: &str,
179    config: &mut IdentityConfig<'_>,
180) -> Option<String> {
181    let role_key = match role {
182        "AUTHOR" => Some(format!("author.{field}")),
183        "COMMITTER" => Some(format!("committer.{field}")),
184        _ => None,
185    };
186    role_key
187        .as_deref()
188        .and_then(|key| identity_config_value(key, config))
189        .or_else(|| identity_config_value(&format!("user.{field}"), config))
190}
191
192pub fn identity_default_value(value: &str, config: &mut IdentityConfig<'_>) -> Option<String> {
193    if identity_use_config_only(config) {
194        None
195    } else {
196        Some(value.to_string())
197    }
198}
199
200pub fn identity_use_config_only(config: &mut IdentityConfig<'_>) -> bool {
201    identity_config_value("user.useconfigonly", config)
202        .as_deref()
203        .and_then(sley_config::parse_config_bool)
204        .unwrap_or(false)
205}
206
207pub fn identity_use_config_only_error<T>() -> Result<T> {
208    sley_core::diagnostic!(
209        Stderr,
210        true,
211        "fatal: no email was given and auto-detection is disabled"
212    );
213    Err(GitError::Rejected(sley_core::RejectionKind::Refused))
214}
215
216pub fn validate_commit_identity_name(role: &str, name: &[u8], email: &[u8]) -> Result<()> {
217    if name.is_empty() {
218        print_identity_unknown_hint(role);
219        sley_core::diagnostic!(
220            Stderr,
221            true,
222            "fatal: empty ident name (for <{}>) not allowed",
223            String::from_utf8_lossy(email)
224        );
225        return Err(GitError::Rejected(sley_core::RejectionKind::Refused));
226    }
227    if !name.iter().any(|byte| !commit_identity_name_crud(*byte)) {
228        sley_core::diagnostic!(
229            Stderr,
230            true,
231            "fatal: name consists only of disallowed characters: {}",
232            String::from_utf8_lossy(name)
233        );
234        return Err(GitError::Rejected(sley_core::RejectionKind::Refused));
235    }
236    Ok(())
237}
238
239pub fn commit_identity_name_crud(byte: u8) -> bool {
240    matches!(
241        byte,
242        0..=32 | b',' | b':' | b';' | b'<' | b'>' | b'"' | b'\\' | b'\''
243    )
244}
245
246pub fn print_identity_unknown_hint(role: &str) {
247    match role {
248        "AUTHOR" => sley_core::diagnostic!(Stderr, true, "Author identity unknown"),
249        "COMMITTER" => sley_core::diagnostic!(Stderr, true, "Committer identity unknown"),
250        _ => {}
251    }
252}
253
254#[cfg(unix)]
255fn argv_bytes_from_os(value: OsString) -> Vec<u8> {
256    use std::os::unix::ffi::OsStrExt;
257    value.as_os_str().as_bytes().to_vec()
258}
259
260#[cfg(not(unix))]
261fn argv_bytes_from_os(value: OsString) -> Vec<u8> {
262    value.to_string_lossy().into_owned().into_bytes()
263}
264
265fn resolve_identity_fields(
266    role: &str,
267    config: &mut IdentityConfig<'_>,
268) -> Option<(Vec<u8>, Vec<u8>)> {
269    let env_name = env::var_os(format!("GIT_{role}_NAME")).map(argv_bytes_from_os);
270    let env_email = env::var_os(format!("GIT_{role}_EMAIL")).map(argv_bytes_from_os);
271    let name = env_name
272        .or_else(|| identity_config_value_for_role(role, "name", config).map(String::into_bytes))
273        .or_else(|| identity_default_value("Git Rs", config).map(String::into_bytes));
274    let email = env_email
275        .or_else(|| identity_config_value_for_role(role, "email", config).map(String::into_bytes))
276        .or_else(|| identity_default_value("sley@example.invalid", config).map(String::into_bytes));
277    Some((name?, email?))
278}
279
280pub fn commit_identity_from_env(role: &str, effective_config: &GitConfig) -> Result<Vec<u8>> {
281    // Higher-precedence env/`-c`/repo sources are evaluated exactly as before;
282    // the global+system config layer is the fallback below repo config.
283    // The effective config is loaded at most once, and only when the env vars do
284    // not already supply both fields, so the common env-driven path is unchanged.
285    let mut config = if env::var_os(format!("GIT_{role}_NAME")).is_none()
286        || env::var_os(format!("GIT_{role}_EMAIL")).is_none()
287    {
288        IdentityConfig::Loaded(effective_config)
289    } else {
290        IdentityConfig::Skip
291    };
292    let Some((name, email)) = resolve_identity_fields(role, &mut config) else {
293        return identity_use_config_only_error();
294    };
295    validate_commit_identity_name(role, &name, &email)?;
296    let date = env::var(format!("GIT_{role}_DATE")).unwrap_or_else(|_| "@0 +0000".into());
297    let date = canonicalize_commit_date(&date);
298    format_commit_identity_bytes(&name, &email, &date)
299}
300
301/// Like [`commit_identity_from_env`] but with the date forced to `date_override`
302/// (any form [`canonicalize_commit_date`] accepts), keeping the env/config
303/// name+email resolution unchanged. Used by `git am
304/// --committer-date-is-author-date`, which keeps the environment committer
305/// name/email but substitutes the author date.
306pub fn commit_identity_from_env_with_date(
307    role: &str,
308    date_override: &str,
309    effective_config: &GitConfig,
310) -> Result<Vec<u8>> {
311    let mut config = if env::var_os(format!("GIT_{role}_NAME")).is_none()
312        || env::var_os(format!("GIT_{role}_EMAIL")).is_none()
313    {
314        IdentityConfig::Loaded(effective_config)
315    } else {
316        IdentityConfig::Skip
317    };
318    let Some((name, email)) = resolve_identity_fields(role, &mut config) else {
319        return identity_use_config_only_error();
320    };
321    validate_commit_identity_name(role, &name, &email)?;
322    let date = canonicalize_commit_date(date_override);
323    format_commit_identity_bytes(&name, &email, &date)
324}
325
326pub fn committer_identity_for_reflog(effective_config: &GitConfig) -> Result<Vec<u8>> {
327    let mut config = if env::var_os("GIT_COMMITTER_NAME").is_none()
328        || env::var_os("GIT_COMMITTER_EMAIL").is_none()
329    {
330        IdentityConfig::Loaded(effective_config)
331    } else {
332        IdentityConfig::Skip
333    };
334    let name = env::var_os("GIT_COMMITTER_NAME")
335        .map(argv_bytes_from_os)
336        .or_else(|| {
337            identity_config_value_for_role("COMMITTER", "name", &mut config).map(String::into_bytes)
338        })
339        .filter(|value| !value.is_empty())
340        .unwrap_or_else(|| b"Git Rs".to_vec());
341    let email = env::var_os("GIT_COMMITTER_EMAIL")
342        .map(argv_bytes_from_os)
343        .or_else(|| {
344            identity_config_value_for_role("COMMITTER", "email", &mut config)
345                .map(String::into_bytes)
346        })
347        .filter(|value| !value.is_empty())
348        .unwrap_or_else(|| b"sley@example.invalid".to_vec());
349    let date = env::var("GIT_COMMITTER_DATE").unwrap_or_else(|_| "@0 +0000".into());
350    let date = canonicalize_commit_date(&date);
351    format_commit_identity_bytes(&name, &email, &date)
352}
353
354pub fn commit_signoff_from_env(effective_config: &GitConfig) -> Result<Vec<u8>> {
355    // git's `--signoff` uses the committer identity, so resolve it with the same
356    // precedence as `commit_identity_from_env("COMMITTER")`.
357    let mut config = if env::var_os("GIT_COMMITTER_NAME").is_none()
358        || env::var_os("GIT_COMMITTER_EMAIL").is_none()
359    {
360        IdentityConfig::Loaded(effective_config)
361    } else {
362        IdentityConfig::Skip
363    };
364    let Some((name, email)) = resolve_identity_fields("COMMITTER", &mut config) else {
365        return identity_use_config_only_error();
366    };
367    validate_commit_identity_name("COMMITTER", &name, &email)?;
368    let date = env::var("GIT_COMMITTER_DATE").unwrap_or_else(|_| "@0 +0000".into());
369    let date = canonicalize_commit_date(&date);
370    format_commit_identity_bytes(&name, &email, &date)?;
371    let mut out = b"Signed-off-by: ".to_vec();
372    out.extend_from_slice(&name);
373    out.extend_from_slice(b" <");
374    out.extend_from_slice(&email);
375    out.push(b'>');
376    Ok(out)
377}
378
379pub fn commit_reflog_message(message: &[u8], amend: bool) -> Vec<u8> {
380    commit_reflog_message_with_initial(message, amend, false)
381}
382
383pub fn commit_reflog_message_with_initial(message: &[u8], amend: bool, initial: bool) -> Vec<u8> {
384    let subject = String::from_utf8_lossy(message)
385        .lines()
386        .next()
387        .unwrap_or("")
388        .to_string();
389    if amend {
390        format!("commit (amend): {subject}").into_bytes()
391    } else if initial {
392        format!("commit (initial): {subject}").into_bytes()
393    } else {
394        format!("commit: {subject}").into_bytes()
395    }
396}
397
398pub fn default_committer() -> Vec<u8> {
399    b"Git Rs <sley@example.invalid> 0 +0000".to_vec()
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn identity_formats_raw_git_date() {
408        let identity =
409            format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
410                .expect("test operation should succeed");
411        assert_eq!(identity, b"Example User <example@example.invalid> 0 +0000");
412    }
413
414    #[test]
415    fn identity_rejects_control_bytes_and_bad_timezones() {
416        assert!(format_commit_identity_bytes(b"na\nme", b"x@y", "@0 +0000").is_err());
417        assert!(format_commit_identity_bytes(b"name", b"x@y", "not-a-date").is_err());
418        assert!(format_commit_identity_bytes(b"name", b"x@y", "@0 +000").is_err());
419        assert!(format_commit_identity_bytes(b"name", b"x@y", "@0 0000").is_err());
420        assert!(format_commit_identity_bytes(b"name", b"x@y", "@0 +0000 extra").is_err());
421    }
422
423    #[test]
424    fn canonicalize_accepts_the_raw_form_and_strips_the_at_sign() {
425        assert_eq!(
426            try_canonicalize_commit_date("@1234 +0530"),
427            Some("1234 +0530".to_string())
428        );
429        assert_eq!(try_canonicalize_commit_date("not a date"), None);
430    }
431
432    #[test]
433    fn canonicalizes_iso_dates_to_raw_seconds() {
434        assert_eq!(
435            canonicalize_commit_date("1970-01-01 00:00:00 +0000"),
436            "0 +0000"
437        );
438    }
439
440    #[test]
441    fn validates_ident_names_like_git() {
442        assert!(validate_commit_identity_name("AUTHOR", b"", b"x@y").is_err());
443        assert!(validate_commit_identity_name("AUTHOR", b"<<<", b"x@y").is_err());
444        assert!(validate_commit_identity_name("AUTHOR", b"A U Thor", b"x@y").is_ok());
445        assert!(commit_identity_name_crud(b'<'));
446        assert!(!commit_identity_name_crud(b'a'));
447    }
448
449    #[test]
450    fn reflog_messages_follow_git_subject_rules() {
451        assert_eq!(
452            commit_reflog_message(b"subject\n\nbody", false),
453            b"commit: subject".to_vec()
454        );
455        assert_eq!(
456            commit_reflog_message(b"subject", true),
457            b"commit (amend): subject".to_vec()
458        );
459        assert_eq!(
460            commit_reflog_message_with_initial(b"subject", false, true),
461            b"commit (initial): subject".to_vec()
462        );
463        assert_eq!(
464            default_committer(),
465            b"Git Rs <sley@example.invalid> 0 +0000"
466        );
467    }
468
469    #[test]
470    fn signoff_uses_committer_identity_shape() {
471        let config = GitConfig::default();
472        // Env-independent shape assertion: the runner may or may not carry
473        // GIT_COMMITTER_* variables, but the trailer format is fixed.
474        if let Ok(signoff) = commit_signoff_from_env(&config) {
475            let text = String::from_utf8_lossy(&signoff).into_owned();
476            assert!(text.starts_with("Signed-off-by: "), "{text}");
477            assert!(text.ends_with('>'), "{text}");
478        }
479    }
480}