1use 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
19pub 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
41pub 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
59pub 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
124pub enum IdentityConfig<'a> {
128 Skip,
129 Loaded(&'a GitConfig),
130}
131
132fn injected_config_value(key: &str) -> Option<String> {
137 let canonical = match sley_config::canonicalize_config_key(key) {
138 Ok(canonical) => canonical,
139 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 ¶m.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
161pub 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 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
301pub 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 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 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}