1use std::ffi::OsString;
7
8use thiserror::Error;
9use time::OffsetDateTime;
10
11use crate::commit::assemble_identity;
12#[cfg(unix)]
13use crate::commit_encoding::decode_bytes;
14use crate::config::ConfigSet;
15use crate::ident::{parse_signature, Signature};
16use crate::ident_config::{env_user_name, ident_default_name};
17
18pub trait IdentityEnv {
20 fn var(&self, key: &str) -> Option<String>;
22
23 fn var_os(&self, key: &str) -> Option<OsString>;
25}
26
27#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum GitIdentityNameEnv {
33 Unset,
35 Set(String),
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum IdentRole {
42 Author,
44 Committer,
46}
47
48impl IdentRole {
49 fn env_name_key(self) -> &'static str {
50 match self {
51 IdentRole::Author => "GIT_AUTHOR_NAME",
52 IdentRole::Committer => "GIT_COMMITTER_NAME",
53 }
54 }
55
56 fn env_email_key(self) -> &'static str {
57 match self {
58 IdentRole::Author => "GIT_AUTHOR_EMAIL",
59 IdentRole::Committer => "GIT_COMMITTER_EMAIL",
60 }
61 }
62
63 fn config_name_key(self) -> &'static str {
64 match self {
65 IdentRole::Author => "author.name",
66 IdentRole::Committer => "committer.name",
67 }
68 }
69
70 fn config_email_key(self) -> &'static str {
71 match self {
72 IdentRole::Author => "author.email",
73 IdentRole::Committer => "committer.email",
74 }
75 }
76
77 #[must_use]
80 pub fn env_date_key(self) -> &'static str {
81 match self {
82 IdentRole::Author => "GIT_AUTHOR_DATE",
83 IdentRole::Committer => "GIT_COMMITTER_DATE",
84 }
85 }
86
87 #[must_use]
89 pub fn missing_email_hint(self) -> &'static str {
90 match self {
91 IdentRole::Author => "Author identity unknown",
92 IdentRole::Committer => "Committer identity unknown",
93 }
94 }
95}
96
97#[derive(Clone, Debug, Error, PartialEq, Eq)]
99pub enum IdentityError {
100 #[error("email auto-detection is disabled (user.useConfigOnly) and no configured email is available")]
105 AutoDetectionDisabled {
106 role: IdentRole,
108 },
109 #[error("no configured email is available for auto-detection")]
111 MissingConfiguredEmail {
112 role: IdentRole,
114 },
115 #[error("empty ident name (for <{email}>) not allowed")]
117 EmptyName {
118 email: String,
120 role: IdentRole,
122 },
123 #[error("invalid ident name: '{name}'")]
125 InvalidName {
126 name: String,
128 },
129 #[error("invalid date '{date}'")]
131 InvalidDate {
132 date: String,
134 role: IdentRole,
136 },
137 #[error(transparent)]
139 InvalidSignature(#[from] crate::ident::SignatureError),
140}
141
142#[must_use]
144pub fn read_git_identity_name_env_with<E: IdentityEnv>(env: &E, key: &str) -> GitIdentityNameEnv {
145 let Some(os) = env.var_os(key) else {
146 return GitIdentityNameEnv::Unset;
147 };
148 #[cfg(unix)]
149 {
150 use std::os::unix::ffi::OsStrExt;
151 let bytes = os.as_bytes();
152 let s = if std::str::from_utf8(bytes).is_ok() {
153 String::from_utf8_lossy(bytes).into_owned()
154 } else {
155 decode_bytes(Some("ISO8859-1"), bytes)
156 };
157 GitIdentityNameEnv::Set(s.trim().to_owned())
158 }
159 #[cfg(not(unix))]
160 {
161 let s = os.to_str().map(|t| t.trim().to_owned()).unwrap_or_default();
162 GitIdentityNameEnv::Set(s)
163 }
164}
165
166fn name_env_with<E: IdentityEnv>(env: &E, key: &str) -> Option<String> {
168 match read_git_identity_name_env_with(env, key) {
169 GitIdentityNameEnv::Set(name) => Some(name),
170 GitIdentityNameEnv::Unset => None,
171 }
172}
173
174fn non_blank(value: Option<String>) -> Option<String> {
176 value.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty())
177}
178
179fn use_config_only(config: &ConfigSet) -> bool {
180 matches!(config.get_bool("user.useConfigOnly"), Some(Ok(true)))
181}
182
183fn config_mail_given(config: &ConfigSet) -> bool {
184 ["user.email", "author.email", "committer.email"]
185 .iter()
186 .any(|key| non_blank(config.get(key)).is_some())
187}
188
189fn ident_name_has_non_crud(name: &str) -> bool {
190 name.chars().any(|c| {
191 let o = c as u32;
192 !(o <= 32
193 || c == ','
194 || c == ':'
195 || c == ';'
196 || c == '<'
197 || c == '>'
198 || c == '"'
199 || c == '\\'
200 || c == '\'')
201 })
202}
203
204fn synthetic_email_with<E: IdentityEnv>(env: &E) -> String {
205 let user = env_user_name(env);
206 let host = env.var("HOSTNAME").unwrap_or_else(|| "unknown".to_owned());
207 let domain = if host.contains('.') {
208 host
209 } else {
210 format!("{host}.(none)")
211 };
212 format!("{user}@{domain}")
213}
214
215pub fn resolve_email_with<E: IdentityEnv>(
226 env: &E,
227 config: &ConfigSet,
228 role: IdentRole,
229) -> Result<String, IdentityError> {
230 let configured = non_blank(env.var(role.env_email_key()))
231 .or_else(|| non_blank(config.get(role.config_email_key())))
232 .or_else(|| non_blank(config.get("user.email")));
233 if let Some(email) = configured {
234 return Ok(email);
235 }
236 if use_config_only(config) && !config_mail_given(config) {
237 return Err(IdentityError::AutoDetectionDisabled { role });
238 }
239 non_blank(env.var("EMAIL")).ok_or(IdentityError::MissingConfiguredEmail { role })
240}
241
242pub fn resolve_name_with<E: IdentityEnv>(
253 env: &E,
254 config: &ConfigSet,
255 role: IdentRole,
256) -> Result<String, IdentityError> {
257 let email = resolve_email_with(env, config, role)?;
258
259 let name = name_env_with(env, role.env_name_key()).unwrap_or_else(|| {
260 non_blank(config.get(role.config_name_key()))
261 .unwrap_or_else(|| ident_default_name(config, env))
262 });
263
264 if name.is_empty() {
265 return Err(IdentityError::EmptyName { email, role });
266 }
267
268 if !ident_name_has_non_crud(&name) {
269 return Err(IdentityError::InvalidName { name });
270 }
271
272 Ok(name)
273}
274
275pub fn resolve_identity_with<E: IdentityEnv>(
293 env: &E,
294 config: &ConfigSet,
295 role: IdentRole,
296 now: OffsetDateTime,
297) -> Result<String, IdentityError> {
298 let name = resolve_name_with(env, config, role)?;
299 let email = resolve_email_with(env, config, role)?;
300 let date = env
301 .var(role.env_date_key())
302 .filter(|d| !d.trim().is_empty());
303 Ok(assemble_identity(&name, &email, date.as_deref(), now))
304}
305
306pub fn resolve_signature_with<E: IdentityEnv>(
325 env: &E,
326 config: &ConfigSet,
327 role: IdentRole,
328 now: OffsetDateTime,
329) -> Result<Signature, IdentityError> {
330 let name = resolve_name_with(env, config, role)?;
331 let email = resolve_email_with(env, config, role)?;
332 let date = env
333 .var(role.env_date_key())
334 .filter(|d| !d.trim().is_empty());
335 let line = assemble_identity(&name, &email, date.as_deref(), now);
336 let time = parse_signature(&line)
337 .ok_or_else(|| IdentityError::InvalidDate {
338 date: date.unwrap_or_default(),
339 role,
340 })?
341 .time;
342 Ok(Signature::new(name, email, time)?)
343}
344
345#[must_use]
359pub fn resolve_loose_committer_identity_with<E: IdentityEnv>(
360 env: &E,
361 config: &ConfigSet,
362 now: OffsetDateTime,
363) -> String {
364 let name = name_env_with(env, "GIT_COMMITTER_NAME")
365 .or_else(|| name_env_with(env, "GIT_AUTHOR_NAME"))
366 .filter(|name| !name.is_empty())
367 .or_else(|| non_blank(config.get("committer.name")))
368 .or_else(|| non_blank(config.get("user.name")))
369 .or_else(|| Some(ident_default_name(config, env)).filter(|name| !name.is_empty()))
370 .unwrap_or_else(|| "Unknown".to_owned());
371 let email = non_blank(env.var("GIT_COMMITTER_EMAIL"))
372 .or_else(|| non_blank(env.var("GIT_AUTHOR_EMAIL")))
373 .or_else(|| non_blank(config.get("committer.email")))
374 .or_else(|| non_blank(config.get("user.email")))
375 .or_else(|| non_blank(env.var("EMAIL")))
376 .unwrap_or_else(|| synthetic_email_with(env));
377 assemble_identity(&name, &email, None, now)
378}
379
380#[cfg(test)]
381mod tests {
382 use std::collections::HashMap;
383 use std::ffi::OsString;
384
385 use super::*;
386
387 struct MapEnv(HashMap<String, String>);
388
389 impl IdentityEnv for MapEnv {
390 fn var(&self, key: &str) -> Option<String> {
391 self.0.get(key).cloned()
392 }
393
394 fn var_os(&self, key: &str) -> Option<OsString> {
395 self.var(key).map(OsString::from)
396 }
397 }
398
399 #[test]
400 fn strict_email_resolution_fails_without_configured_sources() {
401 let env = MapEnv(HashMap::from([("USER".to_owned(), "alice".to_owned())]));
402 let config = ConfigSet::default();
403 let err = resolve_email_with(&env, &config, IdentRole::Author).expect_err("strict");
404 assert_eq!(
405 err,
406 IdentityError::MissingConfiguredEmail {
407 role: IdentRole::Author
408 }
409 );
410 }
411
412 #[test]
413 fn strict_email_falls_back_to_email_env_unless_config_only() {
414 let env = vars(&[("EMAIL", " mail@example.com ")]);
415 let mut config = ConfigSet::default();
416 assert_eq!(
417 resolve_email_with(&env, &config, IdentRole::Author).expect("EMAIL"),
418 "mail@example.com"
419 );
420 config
421 .add_command_override("user.useConfigOnly", "true")
422 .expect("override");
423 assert_eq!(
424 resolve_email_with(&env, &config, IdentRole::Author).expect_err("config only"),
425 IdentityError::AutoDetectionDisabled {
426 role: IdentRole::Author
427 }
428 );
429 config
430 .add_command_override("author.email", " ")
431 .expect("override");
432 config
433 .add_command_override("committer.email", "c@example.com")
434 .expect("override");
435 assert_eq!(
436 resolve_email_with(&env, &config, IdentRole::Author).expect("mail given"),
437 "mail@example.com",
438 "a configured email for any role re-enables the EMAIL fallback"
439 );
440 }
441
442 #[test]
443 fn strict_name_keeps_set_but_empty_env_override() {
444 let env = vars(&[("GIT_AUTHOR_NAME", " "), ("GIT_AUTHOR_EMAIL", "a@b.c")]);
445 let mut config = ConfigSet::default();
446 config
447 .add_command_override("user.name", "Alice")
448 .expect("override");
449 assert_eq!(
450 resolve_name_with(&env, &config, IdentRole::Author).expect_err("empty"),
451 IdentityError::EmptyName {
452 email: "a@b.c".to_owned(),
453 role: IdentRole::Author
454 }
455 );
456 let env = vars(&[("GIT_AUTHOR_EMAIL", "a@b.c")]);
457 config
458 .add_command_override("author.name", " Author Name ")
459 .expect("override");
460 assert_eq!(
461 resolve_name_with(&env, &config, IdentRole::Author).expect("config"),
462 "Author Name"
463 );
464 assert_eq!(
465 resolve_name_with(
466 &vars(&[("GIT_AUTHOR_EMAIL", "a@b.c"), ("GIT_AUTHOR_NAME", ",;")]),
467 &config,
468 IdentRole::Author
469 )
470 .expect_err("crud"),
471 IdentityError::InvalidName {
472 name: ",;".to_owned()
473 }
474 );
475 }
476
477 #[test]
478 fn strict_email_honors_user_email_config() {
479 let env = MapEnv(HashMap::new());
480 let mut config = ConfigSet::default();
481 config
482 .add_command_override("user.email", "me@example.com")
483 .expect("override");
484 let email = resolve_email_with(&env, &config, IdentRole::Committer).expect("configured");
485 assert_eq!(email, "me@example.com");
486 }
487
488 fn vars(pairs: &[(&str, &str)]) -> MapEnv {
489 MapEnv(
490 pairs
491 .iter()
492 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
493 .collect(),
494 )
495 }
496
497 fn fixed_now() -> OffsetDateTime {
498 OffsetDateTime::from_unix_timestamp(1_112_911_993)
499 .expect("epoch")
500 .to_offset(time::UtcOffset::from_hms(-7, 0, 0).expect("offset"))
501 }
502
503 #[test]
504 fn identity_line_uses_now_without_date_override() {
505 let env = vars(&[
506 ("GIT_AUTHOR_NAME", "A U Thor"),
507 ("GIT_AUTHOR_EMAIL", "author@example.com"),
508 ]);
509 let line =
510 resolve_identity_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now())
511 .expect("identity");
512 assert_eq!(line, "A U Thor <author@example.com> 1112911993 -0700");
513 }
514
515 #[test]
516 fn identity_line_honours_only_its_own_role_date() {
517 let env = vars(&[
518 ("GIT_AUTHOR_NAME", "A U Thor"),
519 ("GIT_AUTHOR_EMAIL", "author@example.com"),
520 ("GIT_COMMITTER_NAME", "C O Mitter"),
521 ("GIT_COMMITTER_EMAIL", "committer@example.com"),
522 ("GIT_COMMITTER_DATE", "@1234567890 +0100"),
523 ]);
524 let config = ConfigSet::default();
525 let committer = resolve_identity_with(&env, &config, IdentRole::Committer, fixed_now())
526 .expect("committer");
527 assert_eq!(
528 committer,
529 "C O Mitter <committer@example.com> 1234567890 +0100"
530 );
531 let author =
532 resolve_identity_with(&env, &config, IdentRole::Author, fixed_now()).expect("author");
533 assert_eq!(author, "A U Thor <author@example.com> 1112911993 -0700");
534 }
535
536 #[test]
537 fn identity_line_treats_blank_date_override_as_unset() {
538 let env = vars(&[
539 ("GIT_AUTHOR_NAME", "A U Thor"),
540 ("GIT_AUTHOR_EMAIL", "author@example.com"),
541 ("GIT_AUTHOR_DATE", " "),
542 ]);
543 let line =
544 resolve_identity_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now())
545 .expect("identity");
546 assert_eq!(line, "A U Thor <author@example.com> 1112911993 -0700");
547 }
548
549 #[test]
550 fn identity_line_fails_like_strict_email_resolution() {
551 let env = vars(&[("USER", "alice"), ("GIT_AUTHOR_NAME", "Alice")]);
552 let err =
553 resolve_identity_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now())
554 .expect_err("no configured email");
555 assert_eq!(
556 err,
557 IdentityError::MissingConfiguredEmail {
558 role: IdentRole::Author
559 }
560 );
561 }
562
563 #[test]
564 fn signature_matches_identity_line_and_honours_role_date() {
565 let env = vars(&[
566 ("GIT_COMMITTER_NAME", "C O Mitter"),
567 ("GIT_COMMITTER_EMAIL", "committer@example.com"),
568 ("GIT_COMMITTER_DATE", "@1234567890 +0100"),
569 ]);
570 let config = ConfigSet::default();
571 let sig = resolve_signature_with(&env, &config, IdentRole::Committer, fixed_now())
572 .expect("signature");
573 assert_eq!(sig.name, "C O Mitter");
574 assert_eq!(sig.email, "committer@example.com");
575 assert_eq!(
576 sig.header_line(),
577 resolve_identity_with(&env, &config, IdentRole::Committer, fixed_now()).expect("line")
578 );
579 }
580
581 #[test]
582 fn signature_rejects_unparsable_date_and_unwritable_email() {
583 let env = vars(&[
584 ("GIT_AUTHOR_NAME", "A U Thor"),
585 ("GIT_AUTHOR_EMAIL", "author@example.com"),
586 ("GIT_AUTHOR_DATE", "not a date"),
587 ]);
588 assert_eq!(
589 resolve_signature_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now()),
590 Err(IdentityError::InvalidDate {
591 date: "not a date".to_owned(),
592 role: IdentRole::Author,
593 })
594 );
595 let env = vars(&[
596 ("GIT_AUTHOR_NAME", "A U Thor"),
597 ("GIT_AUTHOR_EMAIL", "a@b> x"),
598 ]);
599 assert!(matches!(
600 resolve_signature_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now()),
601 Err(IdentityError::InvalidSignature(_))
602 ));
603 let env = vars(&[("USER", "alice"), ("GIT_AUTHOR_NAME", "Alice")]);
604 assert_eq!(
605 resolve_signature_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now()),
606 Err(IdentityError::MissingConfiguredEmail {
607 role: IdentRole::Author
608 })
609 );
610 }
611
612 #[test]
613 fn loose_committer_identity_never_fails_and_ignores_date_override() {
614 let env = vars(&[
615 ("USER", "alice"),
616 ("HOSTNAME", "host.example"),
617 ("GIT_COMMITTER_DATE", "@1234567890 +0100"),
618 ]);
619 let mut config = ConfigSet::default();
620 config
621 .add_command_override("user.name", "Alice")
622 .expect("override");
623 let line = resolve_loose_committer_identity_with(&env, &config, fixed_now());
624 assert_eq!(line, "Alice <alice@host.example> 1112911993 -0700");
625 }
626
627 #[test]
628 fn loose_committer_identity_prefers_committer_then_author_env() {
629 let env = vars(&[
630 ("GIT_COMMITTER_NAME", "C O Mitter"),
631 ("GIT_AUTHOR_NAME", "A U Thor"),
632 ("GIT_COMMITTER_EMAIL", " "),
633 ("GIT_AUTHOR_EMAIL", "author@example.com"),
634 ]);
635 let line = resolve_loose_committer_identity_with(&env, &ConfigSet::default(), fixed_now());
636 assert_eq!(line, "C O Mitter <author@example.com> 1112911993 -0700");
637
638 let env = vars(&[("GIT_AUTHOR_NAME", "A U Thor"), ("EMAIL", "e@example.com")]);
639 let line = resolve_loose_committer_identity_with(&env, &ConfigSet::default(), fixed_now());
640 assert_eq!(line, "A U Thor <e@example.com> 1112911993 -0700");
641 }
642
643 #[test]
644 fn loose_committer_identity_empty_committer_name_skips_author_env() {
645 let env = vars(&[
646 ("GIT_COMMITTER_NAME", ""),
647 ("GIT_AUTHOR_NAME", "A U Thor"),
648 ("USER", "alice"),
649 ("HOSTNAME", "box"),
650 ]);
651 let mut config = ConfigSet::default();
652 config
653 .add_command_override("user.name", "User Name")
654 .expect("override");
655 config
656 .add_command_override("committer.name", " Committer Name ")
657 .expect("override");
658 config
659 .add_command_override("user.email", "user@example.com")
660 .expect("override");
661 let line = resolve_loose_committer_identity_with(&env, &config, fixed_now());
662 assert_eq!(line, "Committer Name <user@example.com> 1112911993 -0700");
663
664 let mut config = ConfigSet::default();
665 config
666 .add_command_override("user.name", "User Name")
667 .expect("override");
668 let line = resolve_loose_committer_identity_with(&env, &config, fixed_now());
669 assert_eq!(line, "User Name <alice@box.(none)> 1112911993 -0700");
670 }
671
672 #[test]
673 fn env_date_key_matches_role() {
674 assert_eq!(IdentRole::Author.env_date_key(), "GIT_AUTHOR_DATE");
675 assert_eq!(IdentRole::Committer.env_date_key(), "GIT_COMMITTER_DATE");
676 }
677}