1use crate::commit::{assemble_identity, format_git_timestamp};
17use crate::git_date::tm::date_overflows;
18use crate::objects::{HashAlgo, ObjectKind};
19use time::format_description::well_known::Rfc3339;
20use time::{OffsetDateTime, UtcOffset};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SignatureTimestamp {
25 Valid(i64),
27 Sentinel,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Signature {
35 pub name: String,
37 pub email: String,
39 pub time: OffsetDateTime,
41}
42
43impl Signature {
44 pub fn new(
71 name: impl Into<String>,
72 email: impl Into<String>,
73 time: OffsetDateTime,
74 ) -> Result<Self, SignatureError> {
75 let signature = Self {
76 name: name.into(),
77 email: email.into(),
78 time,
79 };
80 signature.validate()?;
81 Ok(signature)
82 }
83
84 pub fn cleaned(name: &str, email: &str, time: OffsetDateTime) -> Result<Self, SignatureError> {
119 Self::new(clean_ident_part(name), clean_ident_part(email), time)
120 }
121
122 pub fn validate(&self) -> Result<(), SignatureError> {
132 for (field, value) in [
133 (SignatureField::Name, &self.name),
134 (SignatureField::Email, &self.email),
135 ] {
136 if let Some(ch) = value
137 .chars()
138 .find(|&c| c == '<' || c == '>' || (c.is_ascii_control() && c != '\t'))
139 {
140 return Err(SignatureError::ForbiddenCharacter { field, ch });
141 }
142 }
143 if self.time.unix_timestamp() < 0 {
144 return Err(SignatureError::BeforeEpoch);
145 }
146 if self.time.offset().seconds_past_minute() != 0 {
147 return Err(SignatureError::SubMinuteOffset);
148 }
149 Ok(())
150 }
151
152 #[must_use]
155 pub fn reflog_name_email(&self) -> String {
156 format!("{} <{}>", self.name, self.email)
157 }
158
159 #[must_use]
164 pub fn header_line(&self) -> String {
165 format!(
166 "{} <{}> {}",
167 self.name,
168 self.email,
169 format_git_timestamp(self.time)
170 )
171 }
172
173 #[must_use]
175 pub fn format_rfc3339(&self) -> Option<String> {
176 self.time.format(&Rfc3339).ok()
177 }
178
179 #[must_use]
203 pub fn from_header_lossy(line: &str) -> Self {
204 if let Some(sig) = parse_signature(line) {
205 return sig;
206 }
207 let (name, email) = parse_identity_name_email(line)
208 .unwrap_or_else(|| (line.trim().to_owned(), String::new()));
209 Self {
210 name,
211 email,
212 time: OffsetDateTime::UNIX_EPOCH,
213 }
214 }
215}
216
217#[cfg(feature = "serde-models")]
222impl serde::Serialize for Signature {
223 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
224 where
225 S: serde::Serializer,
226 {
227 use serde::ser::SerializeStruct;
228
229 let date = self
230 .format_rfc3339()
231 .unwrap_or_else(|| format_git_timestamp(self.time));
232 let mut out = serializer.serialize_struct("Signature", 3)?;
233 out.serialize_field("name", &self.name)?;
234 out.serialize_field("email", &self.email)?;
235 out.serialize_field("date", &date)?;
236 out.end()
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum SignatureField {
243 Name,
245 Email,
247}
248
249impl std::fmt::Display for SignatureField {
250 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251 f.write_str(match self {
252 Self::Name => "name",
253 Self::Email => "email",
254 })
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
260#[non_exhaustive]
261pub enum SignatureError {
262 #[error("signature {field} contains forbidden character {ch:?}")]
264 ForbiddenCharacter {
265 field: SignatureField,
267 ch: char,
269 },
270 #[error("signature time is before the Unix epoch")]
272 BeforeEpoch,
273 #[error("signature UTC offset is not a whole number of minutes")]
275 SubMinuteOffset,
276 #[error("not an identity line of the form 'Name <email> <unix-seconds> <+HHMM>'")]
278 Malformed,
279}
280
281fn clean_ident_part(part: &str) -> String {
285 let is_crud = |c: char| {
286 c.is_whitespace()
287 || c.is_ascii_control()
288 || matches!(c, '.' | ',' | ':' | ';' | '<' | '>' | '"' | '\\' | '\'')
289 };
290 part.chars()
291 .filter(|&c| c != '<' && c != '>' && !(c.is_ascii_control() && c != '\t'))
292 .collect::<String>()
293 .trim_matches(is_crud)
294 .to_owned()
295}
296
297impl std::str::FromStr for Signature {
298 type Err = SignatureError;
299
300 fn from_str(line: &str) -> Result<Self, Self::Err> {
308 let ends_at_timezone =
309 parse_signature_times(line).is_some_and(|t| t.tz_hhmm_range.end == line.len());
310 if !ends_at_timezone || line.matches('<').count() != 1 || line.matches('>').count() != 1 {
311 return Err(SignatureError::Malformed);
312 }
313 let signature = parse_signature(line).ok_or(SignatureError::Malformed)?;
314 signature.validate()?;
315 Ok(signature)
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct ParsedSignatureTimes {
322 pub unix_seconds: i64,
324 pub tz_offset_secs: i64,
326 pub tz_hhmm_range: std::ops::Range<usize>,
328}
329
330fn scan_decimal_timestamp(bytes: &[u8], mut i: usize) -> Option<(u128, usize)> {
333 const MAX_DIGITS: usize = 21;
334 let start = i;
335 let mut count = 0usize;
336 while i < bytes.len() && bytes[i].is_ascii_digit() {
337 count += 1;
338 if count > MAX_DIGITS {
339 return Some((u128::MAX, i));
340 }
341 i += 1;
342 }
343 if count == 0 {
344 return None;
345 }
346 let s = std::str::from_utf8(&bytes[start..i]).ok()?;
347 let v: u128 = s.parse().ok()?;
348 Some((v, i))
349}
350
351fn skip_fsck_date_leading_ws(bytes: &[u8], mut i: usize) -> usize {
353 while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
354 i += 1;
355 }
356 i
357}
358
359fn parse_tz_hhmm_offset(offset: &str) -> Option<i64> {
360 let b = offset.as_bytes();
361 if b.len() < 5 {
362 return None;
363 }
364 if !(b[0] == b'+' || b[0] == b'-') {
365 return None;
366 }
367 let sign = if b[0] == b'-' { -1i64 } else { 1i64 };
368 let hours: i64 = std::str::from_utf8(&b[1..3]).ok()?.parse().ok()?;
369 let minutes: i64 = std::str::from_utf8(&b[3..5]).ok()?.parse().ok()?;
370 Some(sign * (hours * 3600 + minutes * 60))
371}
372
373#[must_use]
375pub fn parse_signature_times(ident: &str) -> Option<ParsedSignatureTimes> {
376 match parse_signature_tail(ident)? {
377 SignatureTail::Valid(p) => Some(p),
378 SignatureTail::Overflow | SignatureTail::NonNumeric => None,
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq)]
384pub enum SignatureTail {
385 Valid(ParsedSignatureTimes),
387 Overflow,
390 NonNumeric,
392}
393
394#[must_use]
396pub fn parse_signature_tail(ident: &str) -> Option<SignatureTail> {
397 let bytes = ident.as_bytes();
398 let gt = ident.rfind('>')?;
399 let mut i = skip_fsck_date_leading_ws(bytes, gt + 1);
400 if i >= bytes.len() || !bytes[i].is_ascii_digit() {
401 return Some(SignatureTail::NonNumeric);
402 }
403 let (raw, after_digits) = scan_decimal_timestamp(bytes, i)?;
404 if after_digits >= bytes.len() || bytes[after_digits] != b' ' {
405 return None;
406 }
407 i = after_digits + 1;
408 if i + 5 > bytes.len() {
409 return None;
410 }
411 let tz_slice = ident.get(i..i + 5)?;
412 let tz_offset_secs = parse_tz_hhmm_offset(tz_slice)?;
413 let tz_hhmm_range = i..i + 5;
414 if raw == u128::MAX || raw > u64::MAX as u128 || date_overflows(raw as u64) {
415 return Some(SignatureTail::Overflow);
416 }
417 let unix_seconds = i64::try_from(raw).ok()?;
418 Some(SignatureTail::Valid(ParsedSignatureTimes {
419 unix_seconds,
420 tz_offset_secs,
421 tz_hhmm_range,
422 }))
423}
424
425#[must_use]
430pub fn parse_signature(ident: &str) -> Option<Signature> {
431 let ParsedSignatureTimes {
432 unix_seconds,
433 tz_offset_secs,
434 ..
435 } = match parse_signature_tail(ident)? {
436 SignatureTail::Valid(p) => p,
437 SignatureTail::Overflow | SignatureTail::NonNumeric => return None,
438 };
439 let (name, email) = parse_identity_name_email(ident)?;
440 let offset_secs = i32::try_from(tz_offset_secs).ok()?;
441 let offset = UtcOffset::from_whole_seconds(offset_secs).ok()?;
442 let utc = OffsetDateTime::from_unix_timestamp(unix_seconds).ok()?;
443 Some(Signature {
444 name,
445 email,
446 time: utc.to_offset(offset),
447 })
448}
449
450fn parse_identity_name_email(ident: &str) -> Option<(String, String)> {
451 let lt = ident.find('<')?;
452 let name = ident[..lt].trim_end().to_owned();
453 let rest = &ident[lt + 1..];
454 let gt = rest.find('>')?;
455 let email = rest[..gt].trim().to_owned();
456 Some((name, email))
457}
458
459pub fn signature_timestamp_for_pretty(ident: &str) -> SignatureTimestamp {
461 match parse_signature_times(ident) {
462 Some(p) => SignatureTimestamp::Valid(p.unix_seconds),
463 None => SignatureTimestamp::Sentinel,
464 }
465}
466
467#[must_use]
471pub fn committer_timestamp_for_until_filter(ident: &str) -> i64 {
472 match signature_timestamp_for_pretty(ident) {
473 SignatureTimestamp::Valid(ts) => ts,
474 SignatureTimestamp::Sentinel => 0,
475 }
476}
477
478#[must_use]
480pub fn timestamp_for_at_ct(ts: SignatureTimestamp) -> Option<i64> {
481 match ts {
482 SignatureTimestamp::Valid(v) => Some(v),
483 SignatureTimestamp::Sentinel => None,
484 }
485}
486
487pub fn fsck_commit_idents(data: &[u8], hash_algo: HashAlgo) -> Result<(), String> {
491 crate::fsck_standalone::fsck_object(
492 ObjectKind::Commit,
493 data,
494 crate::fsck_standalone::FsckObjectOptions::new(hash_algo),
495 )
496 .map_err(|e| e.report_line())
497}
498
499#[must_use]
501pub fn committer_unix_seconds_for_ordering(ident: &str) -> i64 {
502 match signature_timestamp_for_pretty(ident) {
503 SignatureTimestamp::Valid(ts) => ts,
504 SignatureTimestamp::Sentinel => 0,
505 }
506}
507
508#[must_use]
510pub fn committer_unix_seconds_for_ordering_bytes(ident: &[u8]) -> i64 {
511 let Ok(ident) = std::str::from_utf8(ident) else {
512 return 0;
513 };
514 committer_unix_seconds_for_ordering(ident)
515}
516
517#[must_use]
519pub fn committer_timestamp_for_until_filter_bytes(ident: &[u8]) -> i64 {
520 let Ok(ident) = std::str::from_utf8(ident) else {
521 return 0;
522 };
523 committer_timestamp_for_until_filter(ident)
524}
525
526#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct AuthorContact {
529 pub name: String,
531 pub email: String,
533}
534
535impl AuthorContact {
536 pub fn signature(
549 &self,
550 date_override: Option<&str>,
551 now: OffsetDateTime,
552 ) -> Result<Signature, SignatureError> {
553 let line = author_identity_line(self, date_override, now);
554 let time = parse_signature(&line)
555 .ok_or(SignatureError::Malformed)?
556 .time;
557 Signature::new(self.name.clone(), self.email.clone(), time)
558 }
559}
560
561#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
563pub enum AuthorArgError {
564 #[error("invalid author: expected 'Name <email>' or a bare email address")]
566 InvalidFormat,
567 #[error("invalid author: empty email")]
569 EmptyEmail,
570 #[error("invalid author: trailing text after '>'")]
572 TrailingText,
573}
574
575pub fn parse_author_arg(raw: &str) -> Result<AuthorContact, AuthorArgError> {
581 let s = raw.trim();
582 if s.is_empty() {
583 return Err(AuthorArgError::InvalidFormat);
584 }
585 if s.contains('\n') || s.contains('\r') {
586 return Err(AuthorArgError::InvalidFormat);
587 }
588 if let Some(lt) = s.find('<') {
589 let rest = &s[lt + 1..];
590 let gt = rest.find('>').ok_or(AuthorArgError::InvalidFormat)?;
591 let email = &rest[..gt];
592 if email.is_empty() {
593 return Err(AuthorArgError::EmptyEmail);
594 }
595 let after = rest[gt + 1..].trim();
596 if !after.is_empty() {
597 return Err(AuthorArgError::TrailingText);
598 }
599 let name = s[..lt].trim_end_matches(|c: char| c.is_ascii_whitespace());
600 return Ok(AuthorContact {
601 name: name.to_owned(),
602 email: email.to_owned(),
603 });
604 }
605 if s.contains('@') && !s.chars().any(char::is_whitespace) {
606 return Ok(AuthorContact {
607 name: String::new(),
608 email: s.to_owned(),
609 });
610 }
611 Err(AuthorArgError::InvalidFormat)
612}
613
614#[must_use]
616pub fn author_identity_line(
617 contact: &AuthorContact,
618 date_override: Option<&str>,
619 now: OffsetDateTime,
620) -> String {
621 assemble_identity(&contact.name, &contact.email, date_override, now)
622}
623
624#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
626pub enum CommitIdentityError {
627 #[error("invalid identity: embedded control character")]
629 EmbeddedControl,
630 #[error("invalid identity: expected 'Name <email>', a bare email, or a full header line with timestamp")]
632 Unrecognized,
633 #[error(transparent)]
635 Author(#[from] AuthorArgError),
636}
637
638fn identity_contains_embedded_control(raw: &str) -> bool {
639 raw.bytes()
640 .any(|b| b == b'\n' || b == b'\r' || (b < 0x20 && b != b'\t'))
641}
642
643#[must_use]
645pub fn replace_identity_date_suffix(identity: &str, date_suffix: &str) -> String {
646 let suffix = date_suffix.trim();
647 if let Some((name, email)) = parse_identity_name_email(identity) {
648 if email.is_empty() && !name.is_empty() && name.contains('@') {
649 return format!("{} {suffix}", name.trim());
650 }
651 if email.is_empty() {
652 return format!("{} {suffix}", identity.trim_end());
653 }
654 return format!("{} <{email}> {suffix}", name);
655 }
656 format!("{} {suffix}", identity.trim_end())
657}
658
659pub fn prepare_commit_identity_line(
670 raw: &str,
671 date_override: Option<&str>,
672 now: OffsetDateTime,
673) -> Result<String, CommitIdentityError> {
674 if identity_contains_embedded_control(raw) {
675 return Err(CommitIdentityError::EmbeddedControl);
676 }
677 let trimmed = raw.trim();
678 if trimmed.is_empty() {
679 return Err(CommitIdentityError::Author(AuthorArgError::InvalidFormat));
680 }
681 if matches!(parse_signature_tail(trimmed), Some(SignatureTail::Valid(_))) {
682 let line = trimmed.to_owned();
683 return Ok(match date_override {
684 Some(suffix) => replace_identity_date_suffix(&line, suffix),
685 None => line,
686 });
687 }
688 if let Some(suffix) = date_override {
689 if parse_identity_name_email(trimmed).is_some() {
690 return Ok(replace_identity_date_suffix(trimmed, suffix));
691 }
692 }
693 let contact = parse_author_arg(trimmed)?;
694 Ok(author_identity_line(&contact, date_override, now))
695}
696
697pub(crate) fn prepare_commit_author_from_parent(
700 parent_author: &str,
701 parent_author_raw: &[u8],
702 author_date: Option<&str>,
703 now: OffsetDateTime,
704) -> Result<(String, Vec<u8>), CommitIdentityError> {
705 if let Some(suffix) = author_date {
706 if identity_contains_embedded_control(parent_author) {
707 return Err(CommitIdentityError::EmbeddedControl);
708 }
709 let trimmed = parent_author.trim();
710 if trimmed.is_empty() {
711 return Err(CommitIdentityError::Author(AuthorArgError::InvalidFormat));
712 }
713 return Ok((replace_identity_date_suffix(trimmed, suffix), Vec::new()));
714 }
715 if !parent_author_raw.is_empty() {
716 return Ok((parent_author.to_owned(), parent_author_raw.to_vec()));
717 }
718 Ok((
719 prepare_commit_identity_line(parent_author, None, now)?,
720 Vec::new(),
721 ))
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn cleaned_signatures_are_one_valid_line() {
730 let time = OffsetDateTime::from_unix_timestamp(1_700_000_000)
731 .unwrap_or(OffsetDateTime::UNIX_EPOCH);
732 for (name, email, line) in [
733 (
734 "Ada Lovelace",
735 "ada@example.com",
736 "Ada Lovelace <ada@example.com>",
737 ),
738 (
739 "Eve\nencoding bogus",
740 "e>v<e@x",
741 "Eveencoding bogus <eve@x>",
742 ),
743 (" \"Bob\", ", "<bob@example.com>.", "Bob <bob@example.com>"),
744 ("Tab\there\r\n", "\u{7f}x@y", "Tab\there <x@y>"),
745 ("<>", "", " <>"),
746 ] {
747 let sig = Signature::cleaned(name, email, time);
748 let sig = sig.unwrap_or_else(|e| panic!("{name:?}: {e}"));
749 assert_eq!(
750 sig.header_line(),
751 format!("{line} 1700000000 +0000"),
752 "{name:?}"
753 );
754 assert!(sig.validate().is_ok());
755 assert!(!sig.header_line().contains('\n'));
756 }
757 let before = OffsetDateTime::from_unix_timestamp(-1).unwrap_or(OffsetDateTime::UNIX_EPOCH);
758 assert_eq!(
759 Signature::cleaned("A", "a@x", before),
760 Err(SignatureError::BeforeEpoch)
761 );
762 }
763
764 #[test]
765 fn non_numeric_author_date_is_non_numeric_tail() {
766 let ident = "A <e@x> totally_bogus -0700";
767 assert!(matches!(
768 parse_signature_tail(ident),
769 Some(SignatureTail::NonNumeric)
770 ));
771 }
772
773 #[test]
774 fn u128_max_digit_count_is_overflow_tail() {
775 let ident = "A <e@x> 18446744073709551617 -0700";
776 assert!(matches!(
777 parse_signature_tail(ident),
778 Some(SignatureTail::Overflow)
779 ));
780 }
781
782 #[test]
783 fn parse_author_arg_name_and_email() {
784 let c = parse_author_arg("A U Thor <author@example.com>").expect("parse");
785 assert_eq!(c.name, "A U Thor");
786 assert_eq!(c.email, "author@example.com");
787 }
788
789 #[test]
790 fn parse_author_arg_bare_email() {
791 let c = parse_author_arg("nobody@example.com").expect("parse");
792 assert!(c.name.is_empty());
793 assert_eq!(c.email, "nobody@example.com");
794 }
795
796 #[test]
797 fn parse_author_arg_rejects_empty_email_in_brackets() {
798 assert_eq!(parse_author_arg("Name <>"), Err(AuthorArgError::EmptyEmail));
799 }
800
801 #[test]
802 fn parse_author_arg_rejects_trailing_garbage() {
803 assert_eq!(
804 parse_author_arg("A <a@b.com> extra"),
805 Err(AuthorArgError::TrailingText)
806 );
807 }
808
809 #[test]
810 fn parse_author_arg_rejects_embedded_newline() {
811 assert_eq!(
812 parse_author_arg("Bad\nInjected <x@y.example>"),
813 Err(AuthorArgError::InvalidFormat)
814 );
815 }
816
817 #[test]
818 fn parse_signature_preserves_positive_offset() {
819 let ident = "Ada Lovelace <ada@example.com> 1700000000 +0530";
820 let sig = parse_signature(ident).expect("parse");
821 assert_eq!(sig.name, "Ada Lovelace");
822 assert_eq!(sig.email, "ada@example.com");
823 assert_eq!(
824 sig.format_rfc3339().as_deref(),
825 Some("2023-11-15T03:43:20+05:30")
826 );
827 }
828
829 #[test]
830 fn parse_signature_preserves_negative_offset() {
831 let ident = "Ada Lovelace <ada@example.com> 1700000000 -0800";
832 let sig = parse_signature(ident).expect("parse");
833 assert_eq!(
834 sig.format_rfc3339().as_deref(),
835 Some("2023-11-14T14:13:20-08:00")
836 );
837 }
838
839 #[test]
840 fn signature_from_str_round_trips_header_line() {
841 let line = "Ada Lovelace <ada@example.com> 1700000000 +0530";
842 let sig: Signature = line.parse().expect("parse");
843 assert_eq!(sig.name, "Ada Lovelace");
844 assert_eq!(sig.header_line(), line);
845 }
846
847 #[test]
848 fn signature_from_str_rejects_contact_and_injected_lines() {
849 for line in [
850 "Ada <ada@example.com>",
851 "A <a@b> <c@d> 1 +0000",
852 "Name <a@b>\nInjected: x 1 +0000",
853 "A <e@x> totally_bogus -0700",
854 "A <a@b> 1 +0000 trailing",
855 "A <a@b> 1 +00000",
856 ] {
857 assert_eq!(
858 line.parse::<Signature>(),
859 Err(SignatureError::Malformed),
860 "{line:?}"
861 );
862 }
863 assert!(matches!(
864 "Bad\u{1} <a@b> 1 +0000".parse::<Signature>(),
865 Err(SignatureError::ForbiddenCharacter { .. })
866 ));
867 }
868
869 #[test]
870 fn author_contact_signature_applies_date_override() {
871 let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("epoch");
872 let contact = parse_author_arg("Doc <doc@example.com>").expect("contact");
873 assert_eq!(
874 contact.signature(None, now).expect("now").header_line(),
875 "Doc <doc@example.com> 1700000000 +0000"
876 );
877 assert_eq!(
878 contact
879 .signature(Some("1234567890 -0800"), now)
880 .expect("override")
881 .header_line(),
882 "Doc <doc@example.com> 1234567890 -0800"
883 );
884 assert_eq!(
885 contact.signature(Some("not a date"), now),
886 Err(SignatureError::Malformed)
887 );
888 }
889
890 #[test]
891 fn parse_signature_malformed_identity_is_none() {
892 assert!(parse_signature("A <e@x> totally_bogus -0700").is_none());
893 assert!(parse_signature("not an identity").is_none());
894 }
895
896 #[test]
897 fn prepare_commit_identity_line_expands_contact_form() {
898 let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("epoch");
899 let line =
900 prepare_commit_identity_line("Doc <doc@example.com>", None, now).expect("prepare");
901 assert!(parse_signature(&line).is_some(), "full line: {line}");
902 }
903
904 #[test]
905 fn prepare_commit_identity_line_accepts_full_header() {
906 let ident = "Ada Lovelace <ada@example.com> 1700000000 +0530";
907 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
908 let line = prepare_commit_identity_line(ident, None, now).expect("prepare");
909 assert_eq!(line, ident);
910 }
911
912 #[test]
913 fn prepare_commit_identity_line_rejects_embedded_newline() {
914 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
915 assert_eq!(
916 prepare_commit_identity_line("Name <a@b>\nInjected", None, now),
917 Err(CommitIdentityError::EmbeddedControl)
918 );
919 }
920
921 #[test]
922 fn prepare_commit_identity_line_rejects_bare_name_without_email() {
923 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
924 assert!(matches!(
925 prepare_commit_identity_line("no email here", None, now),
926 Err(CommitIdentityError::Author(AuthorArgError::InvalidFormat))
927 ));
928 }
929
930 #[test]
931 fn prepare_commit_identity_line_date_override_rewrites_non_numeric_tail() {
932 let ident = "A U Thor <author@example.com> totally_bogus -0700";
933 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
934 let line =
935 prepare_commit_identity_line(ident, Some("946684800 +0000"), now).expect("override");
936 assert_eq!(line, "A U Thor <author@example.com> 946684800 +0000");
937 assert!(parse_signature(&line).is_some());
938 }
939
940 #[test]
941 fn prepare_commit_author_from_parent_date_override_rewrites_non_numeric_tail() {
942 let parent = "A U Thor <author@example.com> totally_bogus -0700";
943 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
944 let (line, raw) = prepare_commit_author_from_parent(
945 parent,
946 parent.as_bytes(),
947 Some("946684800 +0000"),
948 now,
949 )
950 .expect("override");
951 assert!(raw.is_empty());
952 assert_eq!(line, "A U Thor <author@example.com> 946684800 +0000");
953 }
954}