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 validate(&self) -> Result<(), SignatureError> {
94 for (field, value) in [
95 (SignatureField::Name, &self.name),
96 (SignatureField::Email, &self.email),
97 ] {
98 if let Some(ch) = value
99 .chars()
100 .find(|&c| c == '<' || c == '>' || (c.is_ascii_control() && c != '\t'))
101 {
102 return Err(SignatureError::ForbiddenCharacter { field, ch });
103 }
104 }
105 if self.time.unix_timestamp() < 0 {
106 return Err(SignatureError::BeforeEpoch);
107 }
108 if self.time.offset().seconds_past_minute() != 0 {
109 return Err(SignatureError::SubMinuteOffset);
110 }
111 Ok(())
112 }
113
114 #[must_use]
117 pub fn reflog_name_email(&self) -> String {
118 format!("{} <{}>", self.name, self.email)
119 }
120
121 #[must_use]
126 pub fn header_line(&self) -> String {
127 format!(
128 "{} <{}> {}",
129 self.name,
130 self.email,
131 format_git_timestamp(self.time)
132 )
133 }
134
135 #[must_use]
137 pub fn format_rfc3339(&self) -> Option<String> {
138 self.time.format(&Rfc3339).ok()
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum SignatureField {
145 Name,
147 Email,
149}
150
151impl std::fmt::Display for SignatureField {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.write_str(match self {
154 Self::Name => "name",
155 Self::Email => "email",
156 })
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
162#[non_exhaustive]
163pub enum SignatureError {
164 #[error("signature {field} contains forbidden character {ch:?}")]
166 ForbiddenCharacter {
167 field: SignatureField,
169 ch: char,
171 },
172 #[error("signature time is before the Unix epoch")]
174 BeforeEpoch,
175 #[error("signature UTC offset is not a whole number of minutes")]
177 SubMinuteOffset,
178 #[error("not an identity line of the form 'Name <email> <unix-seconds> <+HHMM>'")]
180 Malformed,
181}
182
183impl std::str::FromStr for Signature {
184 type Err = SignatureError;
185
186 fn from_str(line: &str) -> Result<Self, Self::Err> {
194 let ends_at_timezone =
195 parse_signature_times(line).is_some_and(|t| t.tz_hhmm_range.end == line.len());
196 if !ends_at_timezone || line.matches('<').count() != 1 || line.matches('>').count() != 1 {
197 return Err(SignatureError::Malformed);
198 }
199 let signature = parse_signature(line).ok_or(SignatureError::Malformed)?;
200 signature.validate()?;
201 Ok(signature)
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct ParsedSignatureTimes {
208 pub unix_seconds: i64,
210 pub tz_offset_secs: i64,
212 pub tz_hhmm_range: std::ops::Range<usize>,
214}
215
216fn scan_decimal_timestamp(bytes: &[u8], mut i: usize) -> Option<(u128, usize)> {
219 const MAX_DIGITS: usize = 21;
220 let start = i;
221 let mut count = 0usize;
222 while i < bytes.len() && bytes[i].is_ascii_digit() {
223 count += 1;
224 if count > MAX_DIGITS {
225 return Some((u128::MAX, i));
226 }
227 i += 1;
228 }
229 if count == 0 {
230 return None;
231 }
232 let s = std::str::from_utf8(&bytes[start..i]).ok()?;
233 let v: u128 = s.parse().ok()?;
234 Some((v, i))
235}
236
237fn skip_fsck_date_leading_ws(bytes: &[u8], mut i: usize) -> usize {
239 while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
240 i += 1;
241 }
242 i
243}
244
245fn parse_tz_hhmm_offset(offset: &str) -> Option<i64> {
246 let b = offset.as_bytes();
247 if b.len() < 5 {
248 return None;
249 }
250 if !(b[0] == b'+' || b[0] == b'-') {
251 return None;
252 }
253 let sign = if b[0] == b'-' { -1i64 } else { 1i64 };
254 let hours: i64 = std::str::from_utf8(&b[1..3]).ok()?.parse().ok()?;
255 let minutes: i64 = std::str::from_utf8(&b[3..5]).ok()?.parse().ok()?;
256 Some(sign * (hours * 3600 + minutes * 60))
257}
258
259#[must_use]
261pub fn parse_signature_times(ident: &str) -> Option<ParsedSignatureTimes> {
262 match parse_signature_tail(ident)? {
263 SignatureTail::Valid(p) => Some(p),
264 SignatureTail::Overflow | SignatureTail::NonNumeric => None,
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
270pub enum SignatureTail {
271 Valid(ParsedSignatureTimes),
273 Overflow,
276 NonNumeric,
278}
279
280#[must_use]
282pub fn parse_signature_tail(ident: &str) -> Option<SignatureTail> {
283 let bytes = ident.as_bytes();
284 let gt = ident.rfind('>')?;
285 let mut i = skip_fsck_date_leading_ws(bytes, gt + 1);
286 if i >= bytes.len() || !bytes[i].is_ascii_digit() {
287 return Some(SignatureTail::NonNumeric);
288 }
289 let (raw, after_digits) = scan_decimal_timestamp(bytes, i)?;
290 if after_digits >= bytes.len() || bytes[after_digits] != b' ' {
291 return None;
292 }
293 i = after_digits + 1;
294 if i + 5 > bytes.len() {
295 return None;
296 }
297 let tz_slice = ident.get(i..i + 5)?;
298 let tz_offset_secs = parse_tz_hhmm_offset(tz_slice)?;
299 let tz_hhmm_range = i..i + 5;
300 if raw == u128::MAX || raw > u64::MAX as u128 || date_overflows(raw as u64) {
301 return Some(SignatureTail::Overflow);
302 }
303 let unix_seconds = i64::try_from(raw).ok()?;
304 Some(SignatureTail::Valid(ParsedSignatureTimes {
305 unix_seconds,
306 tz_offset_secs,
307 tz_hhmm_range,
308 }))
309}
310
311#[must_use]
316pub fn parse_signature(ident: &str) -> Option<Signature> {
317 let ParsedSignatureTimes {
318 unix_seconds,
319 tz_offset_secs,
320 ..
321 } = match parse_signature_tail(ident)? {
322 SignatureTail::Valid(p) => p,
323 SignatureTail::Overflow | SignatureTail::NonNumeric => return None,
324 };
325 let (name, email) = parse_identity_name_email(ident)?;
326 let offset_secs = i32::try_from(tz_offset_secs).ok()?;
327 let offset = UtcOffset::from_whole_seconds(offset_secs).ok()?;
328 let utc = OffsetDateTime::from_unix_timestamp(unix_seconds).ok()?;
329 Some(Signature {
330 name,
331 email,
332 time: utc.to_offset(offset),
333 })
334}
335
336fn parse_identity_name_email(ident: &str) -> Option<(String, String)> {
337 let lt = ident.find('<')?;
338 let name = ident[..lt].trim_end().to_owned();
339 let rest = &ident[lt + 1..];
340 let gt = rest.find('>')?;
341 let email = rest[..gt].trim().to_owned();
342 Some((name, email))
343}
344
345pub fn signature_timestamp_for_pretty(ident: &str) -> SignatureTimestamp {
347 match parse_signature_times(ident) {
348 Some(p) => SignatureTimestamp::Valid(p.unix_seconds),
349 None => SignatureTimestamp::Sentinel,
350 }
351}
352
353#[must_use]
357pub fn committer_timestamp_for_until_filter(ident: &str) -> i64 {
358 match signature_timestamp_for_pretty(ident) {
359 SignatureTimestamp::Valid(ts) => ts,
360 SignatureTimestamp::Sentinel => 0,
361 }
362}
363
364#[must_use]
366pub fn timestamp_for_at_ct(ts: SignatureTimestamp) -> Option<i64> {
367 match ts {
368 SignatureTimestamp::Valid(v) => Some(v),
369 SignatureTimestamp::Sentinel => None,
370 }
371}
372
373pub fn fsck_commit_idents(data: &[u8], hash_algo: HashAlgo) -> Result<(), String> {
377 crate::fsck_standalone::fsck_object(
378 ObjectKind::Commit,
379 data,
380 crate::fsck_standalone::FsckObjectOptions::new(hash_algo),
381 )
382 .map_err(|e| e.report_line())
383}
384
385#[must_use]
387pub fn committer_unix_seconds_for_ordering(ident: &str) -> i64 {
388 match signature_timestamp_for_pretty(ident) {
389 SignatureTimestamp::Valid(ts) => ts,
390 SignatureTimestamp::Sentinel => 0,
391 }
392}
393
394#[must_use]
396pub fn committer_unix_seconds_for_ordering_bytes(ident: &[u8]) -> i64 {
397 let Ok(ident) = std::str::from_utf8(ident) else {
398 return 0;
399 };
400 committer_unix_seconds_for_ordering(ident)
401}
402
403#[must_use]
405pub fn committer_timestamp_for_until_filter_bytes(ident: &[u8]) -> i64 {
406 let Ok(ident) = std::str::from_utf8(ident) else {
407 return 0;
408 };
409 committer_timestamp_for_until_filter(ident)
410}
411
412#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct AuthorContact {
415 pub name: String,
417 pub email: String,
419}
420
421impl AuthorContact {
422 pub fn signature(
435 &self,
436 date_override: Option<&str>,
437 now: OffsetDateTime,
438 ) -> Result<Signature, SignatureError> {
439 let line = author_identity_line(self, date_override, now);
440 let time = parse_signature(&line)
441 .ok_or(SignatureError::Malformed)?
442 .time;
443 Signature::new(self.name.clone(), self.email.clone(), time)
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
449pub enum AuthorArgError {
450 #[error("invalid author: expected 'Name <email>' or a bare email address")]
452 InvalidFormat,
453 #[error("invalid author: empty email")]
455 EmptyEmail,
456 #[error("invalid author: trailing text after '>'")]
458 TrailingText,
459}
460
461pub fn parse_author_arg(raw: &str) -> Result<AuthorContact, AuthorArgError> {
467 let s = raw.trim();
468 if s.is_empty() {
469 return Err(AuthorArgError::InvalidFormat);
470 }
471 if s.contains('\n') || s.contains('\r') {
472 return Err(AuthorArgError::InvalidFormat);
473 }
474 if let Some(lt) = s.find('<') {
475 let rest = &s[lt + 1..];
476 let gt = rest.find('>').ok_or(AuthorArgError::InvalidFormat)?;
477 let email = &rest[..gt];
478 if email.is_empty() {
479 return Err(AuthorArgError::EmptyEmail);
480 }
481 let after = rest[gt + 1..].trim();
482 if !after.is_empty() {
483 return Err(AuthorArgError::TrailingText);
484 }
485 let name = s[..lt].trim_end_matches(|c: char| c.is_ascii_whitespace());
486 return Ok(AuthorContact {
487 name: name.to_owned(),
488 email: email.to_owned(),
489 });
490 }
491 if s.contains('@') && !s.chars().any(char::is_whitespace) {
492 return Ok(AuthorContact {
493 name: String::new(),
494 email: s.to_owned(),
495 });
496 }
497 Err(AuthorArgError::InvalidFormat)
498}
499
500#[must_use]
502pub fn author_identity_line(
503 contact: &AuthorContact,
504 date_override: Option<&str>,
505 now: OffsetDateTime,
506) -> String {
507 assemble_identity(&contact.name, &contact.email, date_override, now)
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
512pub enum CommitIdentityError {
513 #[error("invalid identity: embedded control character")]
515 EmbeddedControl,
516 #[error("invalid identity: expected 'Name <email>', a bare email, or a full header line with timestamp")]
518 Unrecognized,
519 #[error(transparent)]
521 Author(#[from] AuthorArgError),
522}
523
524fn identity_contains_embedded_control(raw: &str) -> bool {
525 raw.bytes()
526 .any(|b| b == b'\n' || b == b'\r' || (b < 0x20 && b != b'\t'))
527}
528
529#[must_use]
531pub fn replace_identity_date_suffix(identity: &str, date_suffix: &str) -> String {
532 let suffix = date_suffix.trim();
533 if let Some((name, email)) = parse_identity_name_email(identity) {
534 if email.is_empty() && !name.is_empty() && name.contains('@') {
535 return format!("{} {suffix}", name.trim());
536 }
537 if email.is_empty() {
538 return format!("{} {suffix}", identity.trim_end());
539 }
540 return format!("{} <{email}> {suffix}", name);
541 }
542 format!("{} {suffix}", identity.trim_end())
543}
544
545pub fn prepare_commit_identity_line(
556 raw: &str,
557 date_override: Option<&str>,
558 now: OffsetDateTime,
559) -> Result<String, CommitIdentityError> {
560 if identity_contains_embedded_control(raw) {
561 return Err(CommitIdentityError::EmbeddedControl);
562 }
563 let trimmed = raw.trim();
564 if trimmed.is_empty() {
565 return Err(CommitIdentityError::Author(AuthorArgError::InvalidFormat));
566 }
567 if matches!(parse_signature_tail(trimmed), Some(SignatureTail::Valid(_))) {
568 let line = trimmed.to_owned();
569 return Ok(match date_override {
570 Some(suffix) => replace_identity_date_suffix(&line, suffix),
571 None => line,
572 });
573 }
574 if let Some(suffix) = date_override {
575 if parse_identity_name_email(trimmed).is_some() {
576 return Ok(replace_identity_date_suffix(trimmed, suffix));
577 }
578 }
579 let contact = parse_author_arg(trimmed)?;
580 Ok(author_identity_line(&contact, date_override, now))
581}
582
583pub(crate) fn prepare_commit_author_from_parent(
586 parent_author: &str,
587 parent_author_raw: &[u8],
588 author_date: Option<&str>,
589 now: OffsetDateTime,
590) -> Result<(String, Vec<u8>), CommitIdentityError> {
591 if let Some(suffix) = author_date {
592 if identity_contains_embedded_control(parent_author) {
593 return Err(CommitIdentityError::EmbeddedControl);
594 }
595 let trimmed = parent_author.trim();
596 if trimmed.is_empty() {
597 return Err(CommitIdentityError::Author(AuthorArgError::InvalidFormat));
598 }
599 return Ok((replace_identity_date_suffix(trimmed, suffix), Vec::new()));
600 }
601 if !parent_author_raw.is_empty() {
602 return Ok((parent_author.to_owned(), parent_author_raw.to_vec()));
603 }
604 Ok((
605 prepare_commit_identity_line(parent_author, None, now)?,
606 Vec::new(),
607 ))
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn non_numeric_author_date_is_non_numeric_tail() {
616 let ident = "A <e@x> totally_bogus -0700";
617 assert!(matches!(
618 parse_signature_tail(ident),
619 Some(SignatureTail::NonNumeric)
620 ));
621 }
622
623 #[test]
624 fn u128_max_digit_count_is_overflow_tail() {
625 let ident = "A <e@x> 18446744073709551617 -0700";
626 assert!(matches!(
627 parse_signature_tail(ident),
628 Some(SignatureTail::Overflow)
629 ));
630 }
631
632 #[test]
633 fn parse_author_arg_name_and_email() {
634 let c = parse_author_arg("A U Thor <author@example.com>").expect("parse");
635 assert_eq!(c.name, "A U Thor");
636 assert_eq!(c.email, "author@example.com");
637 }
638
639 #[test]
640 fn parse_author_arg_bare_email() {
641 let c = parse_author_arg("nobody@example.com").expect("parse");
642 assert!(c.name.is_empty());
643 assert_eq!(c.email, "nobody@example.com");
644 }
645
646 #[test]
647 fn parse_author_arg_rejects_empty_email_in_brackets() {
648 assert_eq!(parse_author_arg("Name <>"), Err(AuthorArgError::EmptyEmail));
649 }
650
651 #[test]
652 fn parse_author_arg_rejects_trailing_garbage() {
653 assert_eq!(
654 parse_author_arg("A <a@b.com> extra"),
655 Err(AuthorArgError::TrailingText)
656 );
657 }
658
659 #[test]
660 fn parse_author_arg_rejects_embedded_newline() {
661 assert_eq!(
662 parse_author_arg("Bad\nInjected <x@y.example>"),
663 Err(AuthorArgError::InvalidFormat)
664 );
665 }
666
667 #[test]
668 fn parse_signature_preserves_positive_offset() {
669 let ident = "Ada Lovelace <ada@example.com> 1700000000 +0530";
670 let sig = parse_signature(ident).expect("parse");
671 assert_eq!(sig.name, "Ada Lovelace");
672 assert_eq!(sig.email, "ada@example.com");
673 assert_eq!(
674 sig.format_rfc3339().as_deref(),
675 Some("2023-11-15T03:43:20+05:30")
676 );
677 }
678
679 #[test]
680 fn parse_signature_preserves_negative_offset() {
681 let ident = "Ada Lovelace <ada@example.com> 1700000000 -0800";
682 let sig = parse_signature(ident).expect("parse");
683 assert_eq!(
684 sig.format_rfc3339().as_deref(),
685 Some("2023-11-14T14:13:20-08:00")
686 );
687 }
688
689 #[test]
690 fn signature_from_str_round_trips_header_line() {
691 let line = "Ada Lovelace <ada@example.com> 1700000000 +0530";
692 let sig: Signature = line.parse().expect("parse");
693 assert_eq!(sig.name, "Ada Lovelace");
694 assert_eq!(sig.header_line(), line);
695 }
696
697 #[test]
698 fn signature_from_str_rejects_contact_and_injected_lines() {
699 for line in [
700 "Ada <ada@example.com>",
701 "A <a@b> <c@d> 1 +0000",
702 "Name <a@b>\nInjected: x 1 +0000",
703 "A <e@x> totally_bogus -0700",
704 "A <a@b> 1 +0000 trailing",
705 "A <a@b> 1 +00000",
706 ] {
707 assert_eq!(
708 line.parse::<Signature>(),
709 Err(SignatureError::Malformed),
710 "{line:?}"
711 );
712 }
713 assert!(matches!(
714 "Bad\u{1} <a@b> 1 +0000".parse::<Signature>(),
715 Err(SignatureError::ForbiddenCharacter { .. })
716 ));
717 }
718
719 #[test]
720 fn author_contact_signature_applies_date_override() {
721 let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("epoch");
722 let contact = parse_author_arg("Doc <doc@example.com>").expect("contact");
723 assert_eq!(
724 contact.signature(None, now).expect("now").header_line(),
725 "Doc <doc@example.com> 1700000000 +0000"
726 );
727 assert_eq!(
728 contact
729 .signature(Some("1234567890 -0800"), now)
730 .expect("override")
731 .header_line(),
732 "Doc <doc@example.com> 1234567890 -0800"
733 );
734 assert_eq!(
735 contact.signature(Some("not a date"), now),
736 Err(SignatureError::Malformed)
737 );
738 }
739
740 #[test]
741 fn parse_signature_malformed_identity_is_none() {
742 assert!(parse_signature("A <e@x> totally_bogus -0700").is_none());
743 assert!(parse_signature("not an identity").is_none());
744 }
745
746 #[test]
747 fn prepare_commit_identity_line_expands_contact_form() {
748 let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("epoch");
749 let line =
750 prepare_commit_identity_line("Doc <doc@example.com>", None, now).expect("prepare");
751 assert!(parse_signature(&line).is_some(), "full line: {line}");
752 }
753
754 #[test]
755 fn prepare_commit_identity_line_accepts_full_header() {
756 let ident = "Ada Lovelace <ada@example.com> 1700000000 +0530";
757 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
758 let line = prepare_commit_identity_line(ident, None, now).expect("prepare");
759 assert_eq!(line, ident);
760 }
761
762 #[test]
763 fn prepare_commit_identity_line_rejects_embedded_newline() {
764 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
765 assert_eq!(
766 prepare_commit_identity_line("Name <a@b>\nInjected", None, now),
767 Err(CommitIdentityError::EmbeddedControl)
768 );
769 }
770
771 #[test]
772 fn prepare_commit_identity_line_rejects_bare_name_without_email() {
773 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
774 assert!(matches!(
775 prepare_commit_identity_line("no email here", None, now),
776 Err(CommitIdentityError::Author(AuthorArgError::InvalidFormat))
777 ));
778 }
779
780 #[test]
781 fn prepare_commit_identity_line_date_override_rewrites_non_numeric_tail() {
782 let ident = "A U Thor <author@example.com> totally_bogus -0700";
783 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
784 let line =
785 prepare_commit_identity_line(ident, Some("946684800 +0000"), now).expect("override");
786 assert_eq!(line, "A U Thor <author@example.com> 946684800 +0000");
787 assert!(parse_signature(&line).is_some());
788 }
789
790 #[test]
791 fn prepare_commit_author_from_parent_date_override_rewrites_non_numeric_tail() {
792 let parent = "A U Thor <author@example.com> totally_bogus -0700";
793 let now = OffsetDateTime::from_unix_timestamp(1).expect("epoch");
794 let (line, raw) = prepare_commit_author_from_parent(
795 parent,
796 parent.as_bytes(),
797 Some("946684800 +0000"),
798 now,
799 )
800 .expect("override");
801 assert!(raw.is_empty());
802 assert_eq!(line, "A U Thor <author@example.com> 946684800 +0000");
803 }
804}