Skip to main content

grit_lib/
ident.rs

1//! Git author/committer identity lines (`ident` in Git's `fsck.c` / `commit.c`).
2//!
3//! Parses `Name <email> <unix-timestamp> <+HHMM>` with the same edge cases as Git:
4//! overflow, non-digit timestamps, and whitespace-only timestamps (sentinel handling).
5//!
6//! ```rust
7//! use grit_lib::ident::parse_author_arg;
8//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
9//! let contact = parse_author_arg("Ada Lovelace <ada@example.com>")?;
10//! assert_eq!(contact.name, "Ada Lovelace");
11//! assert_eq!(contact.email, "ada@example.com");
12//! # Ok(())
13//! # }
14//! ```
15
16use 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/// Parsed timestamp from a signature line for display and filtering.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SignatureTimestamp {
25    /// Parsed seconds since Unix epoch (author/committer field); safe for `time_t` / display.
26    Valid(i64),
27    /// Unparsable, overflowing, or whitespace-only date field — Git uses a sentinel (epoch in
28    /// headers, empty `%ad`, empty `%at` / `%ct` in format).
29    Sentinel,
30}
31
32/// Parsed author or committer identity from a full header or reflog identity line.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Signature {
35    /// Display name (may be empty).
36    pub name: String,
37    /// Email address inside `<…>`.
38    pub email: String,
39    /// Timestamp with the recorded `±HHMM` offset preserved.
40    pub time: OffsetDateTime,
41}
42
43impl Signature {
44    /// Build a signature for writing into a commit or tag.
45    ///
46    /// # Parameters
47    ///
48    /// - `name`: display name (may be empty).
49    /// - `email`: address written inside `<…>` (may be empty).
50    /// - `time`: timestamp; its UTC offset becomes the `±HHMM` field.
51    ///
52    /// # Errors
53    ///
54    /// See [`Self::validate`].
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use grit_lib::ident::Signature;
60    /// use time::{OffsetDateTime, UtcOffset};
61    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
62    /// let time = OffsetDateTime::from_unix_timestamp(1_700_000_000)?
63    ///     .to_offset(UtcOffset::from_hms(-8, 0, 0)?);
64    /// let sig = Signature::new("Ada Lovelace", "ada@example.com", time)?;
65    /// assert_eq!(sig.header_line(), "Ada Lovelace <ada@example.com> 1700000000 -0800");
66    /// assert!(Signature::new("Ada <x>", "ada@example.com", time).is_err());
67    /// # Ok(())
68    /// # }
69    /// ```
70    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    /// Build a signature from a name and email that may hold characters an
85    /// identity line can't, cleaning them as Git cleans `GIT_AUTHOR_NAME` and
86    /// friends: `<`, `>` and control characters other than tab are dropped,
87    /// and punctuation and whitespace are trimmed from both ends. Use it for
88    /// identities from untrusted input, such as a request to a server, so
89    /// nothing in them can end the line early or add a header.
90    ///
91    /// # Parameters
92    ///
93    /// - `name`: display name; empty after cleaning is allowed.
94    /// - `email`: address written inside `<…>`; empty after cleaning is allowed.
95    /// - `time`: timestamp; its UTC offset becomes the `±HHMM` field.
96    ///
97    /// # Returns
98    ///
99    /// A signature whose [`Self::header_line`] `git fsck` accepts.
100    ///
101    /// # Errors
102    ///
103    /// [`SignatureError::BeforeEpoch`] and [`SignatureError::SubMinuteOffset`]
104    /// for a `time` [`Self::validate`] refuses; the name and email always pass.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use grit_lib::ident::Signature;
110    /// use time::OffsetDateTime;
111    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
112    /// let time = OffsetDateTime::from_unix_timestamp(1_700_000_000)?;
113    /// let sig = Signature::cleaned("Eve\nencoding bogus", " <e>v<e@x> ", time)?;
114    /// assert_eq!(sig.header_line(), "Eveencoding bogus <eve@x> 1700000000 +0000");
115    /// # Ok(())
116    /// # }
117    /// ```
118    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    /// Check that [`Self::header_line`] yields an identity line `git fsck` accepts.
123    ///
124    /// # Errors
125    ///
126    /// - [`SignatureError::ForbiddenCharacter`] when the name or email contains `<`, `>`,
127    ///   or a control character other than tab.
128    /// - [`SignatureError::BeforeEpoch`] when the time is before 1970-01-01 UTC.
129    /// - [`SignatureError::SubMinuteOffset`] when the UTC offset has a seconds component,
130    ///   which the `±HHMM` field cannot represent.
131    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    /// `Name <email>` for a reflog identity column; the timestamp is taken from
153    /// [`crate::refs::store::ReflogUpdate::time`] when the entry is formatted.
154    #[must_use]
155    pub fn reflog_name_email(&self) -> String {
156        format!("{} <{}>", self.name, self.email)
157    }
158
159    /// Render as a header payload: `Name <email> <unix-seconds> <±HHMM>`.
160    ///
161    /// The result is what follows `author ` / `committer ` / `tagger ` in an object.
162    /// It is only fsck-clean when [`Self::validate`] succeeds.
163    #[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    /// Format [`Self::time`] as RFC 3339 (includes the original offset).
174    #[must_use]
175    pub fn format_rfc3339(&self) -> Option<String> {
176        self.time.format(&Rfc3339).ok()
177    }
178
179    /// Parse an identity line as stored in a commit or tag, accepting what `git log` accepts.
180    ///
181    /// Unlike [`str::parse`], this never fails: a missing `<email>` leaves
182    /// [`Self::email`] empty with the whole text as the name, and a missing,
183    /// non-numeric or overflowing date becomes the Unix epoch in UTC, the same
184    /// sentinel Git shows for such commits.
185    ///
186    /// # Parameters
187    ///
188    /// - `line`: the text after `author ` / `committer ` / `tagger ` in an object header.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// use grit_lib::ident::Signature;
194    ///
195    /// let sig = Signature::from_header_lossy("Ada <ada@example.com> 1700000000 +0100");
196    /// assert_eq!(sig.email, "ada@example.com");
197    /// assert_eq!(sig.time.unix_timestamp(), 1_700_000_000);
198    ///
199    /// let bogus = Signature::from_header_lossy("Ada <ada@example.com> totally_bogus");
200    /// assert_eq!(bogus.time.unix_timestamp(), 0);
201    /// ```
202    #[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/// Serializes as `{"name", "email", "date"}` with `date` in RFC 3339 (original offset kept).
218///
219/// A time RFC 3339 cannot represent (a year past 9999) falls back to Git's
220/// `<unix-seconds> <±HHMM>` text.
221#[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/// Which part of a [`Signature`] a [`SignatureError`] refers to.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum SignatureField {
243    /// [`Signature::name`].
244    Name,
245    /// [`Signature::email`].
246    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/// Why a [`Signature`] cannot be written into an object (see [`Signature::validate`]).
259#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
260#[non_exhaustive]
261pub enum SignatureError {
262    /// The name or email contains a character that would break the identity line.
263    #[error("signature {field} contains forbidden character {ch:?}")]
264    ForbiddenCharacter {
265        /// Field holding the character.
266        field: SignatureField,
267        /// The first forbidden character.
268        ch: char,
269    },
270    /// Git timestamps are unsigned seconds since the Unix epoch.
271    #[error("signature time is before the Unix epoch")]
272    BeforeEpoch,
273    /// The UTC offset is not a whole number of minutes.
274    #[error("signature UTC offset is not a whole number of minutes")]
275    SubMinuteOffset,
276    /// The text is not a `Name <email> <unix-seconds> <±HHMM>` identity line.
277    #[error("not an identity line of the form 'Name <email> <unix-seconds> <+HHMM>'")]
278    Malformed,
279}
280
281/// `part` without the characters an identity line can't hold (`<`, `>` and
282/// control characters other than tab), and without the punctuation and
283/// whitespace Git trims from the ends of a name or email.
284fn 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    /// Parse a full header identity line (`Name <email> <unix-seconds> <±HHMM>`).
301    ///
302    /// # Errors
303    ///
304    /// [`SignatureError::Malformed`] when the line does not have exactly one `<email>`, has a
305    /// missing, non-numeric, or overflowing date, or has text after the `±HHMM` timezone;
306    /// otherwise the [`Signature::validate`] errors.
307    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/// Successful parse of the trailing `<unix> <+HHMM>` portion of a signature.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct ParsedSignatureTimes {
322    /// Seconds in the author/committer field (before tz offset).
323    pub unix_seconds: i64,
324    /// Signed offset in seconds (from `+HHMM` / `-HHMM`).
325    pub tz_offset_secs: i64,
326    /// Byte range of the `+HHMM` / `-HHMM` field in the original `ident` string.
327    pub tz_hhmm_range: std::ops::Range<usize>,
328}
329
330/// Scan a decimal timestamp like Git's `parse_timestamp_from_buf` / `strtoumax`.
331/// Returns `None` if there is no digit or more than 21 digits (Git uses a 24-byte buffer).
332fn 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
351/// After `>` and one required space, skip only ASCII spaces and horizontal tabs (Git `fsck_ident`).
352fn 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/// Parse `<unix> <+HHMM>` after the closing `>` of the email (Git commit author/committer line).
374#[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/// Distinguishes a non-numeric date field from a numeric field that fails Git's overflow rules.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub enum SignatureTail {
385    /// Well-formed timestamp and timezone.
386    Valid(ParsedSignatureTimes),
387    /// Digits and timezone present, but the number does not fit Git's `date_overflows` rules.
388    /// Default `%ad` shows the Unix epoch with `+0000` (t4212).
389    Overflow,
390    /// No leading digit after `>` (e.g. `totally_bogus`): `%ad` is empty, headers use epoch `+0000`.
391    NonNumeric,
392}
393
394/// Parse the date/time tail like [`parse_signature_times`], but preserve overflow vs non-numeric.
395#[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/// Parse `Name <email> <unix> <±HHMM>` into a [`Signature`].
426///
427/// Returns [`None`] when the date field is missing, malformed, overflowed, or non-numeric
428/// (no sentinel epoch is synthesized).
429#[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
459/// Classify the date field for pretty-print / `%at` (t4212 whitespace commits).
460pub 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/// Unix timestamp for `--until` / `--since` filtering (committer), matching Git's `parse_date`.
468/// Sentinel timestamps (whitespace dates, overflow, etc.) behave like `0` for cutoff comparisons
469/// (see t4212 `rev-list --until`).
470#[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/// Raw unix seconds as i64 for `%at` when valid; `None` when Git would print nothing.
479#[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
487/// First fsck error for commit headers (tree/parents/author/committer), or `Ok`.
488///
489/// Returns a [`crate::fsck_standalone::FsckError::report_line`] string (`msg-id: detail`); compare msg-ids to Git, not detail text.
490pub 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/// Committer seconds for ordering (`rev-list --date-order`, etc.): unknown/corrupt → `0`.
500#[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/// Like [`committer_unix_seconds_for_ordering`] on raw identity bytes (ASCII commit headers).
509#[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/// Like [`committer_timestamp_for_until_filter`] on raw identity bytes (ASCII commit headers).
518#[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/// Name and email from a `git commit --author`-style argument (`Name <email>` or bare email).
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct AuthorContact {
529    /// Display name (may be empty when the argument is a bare email address).
530    pub name: String,
531    /// Email address inside `<…>`.
532    pub email: String,
533}
534
535impl AuthorContact {
536    /// Stamp this contact with a timestamp, giving a [`Signature`].
537    ///
538    /// # Parameters
539    ///
540    /// - `date_override`: a `GIT_AUTHOR_DATE`-style date (`@<unix>`, `<unix> <±HHMM>`, ISO 8601,
541    ///   RFC 2822, …); when `None`, `now` is used.
542    /// - `now`: the current time; its UTC offset is used for `@<unix>` overrides.
543    ///
544    /// # Errors
545    ///
546    /// [`SignatureError::Malformed`] when `date_override` is not a date Git accepts, and the
547    /// [`Signature::validate`] errors for the name and email.
548    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/// Failure to parse [`parse_author_arg`].
562#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
563pub enum AuthorArgError {
564    /// The string is not a recognized author form.
565    #[error("invalid author: expected 'Name <email>' or a bare email address")]
566    InvalidFormat,
567    /// Angle brackets present but the email portion is empty.
568    #[error("invalid author: empty email")]
569    EmptyEmail,
570    /// Non-whitespace remains after the closing `>`.
571    #[error("invalid author: trailing text after '>'")]
572    TrailingText,
573}
574
575/// Parse `git commit --author` values: `Name <email>` or a bare email (no whitespace).
576///
577/// # Errors
578///
579/// Returns [`AuthorArgError`] when the string cannot be interpreted as an author.
580pub 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/// Build a full author/committer header line from a parsed contact and timestamp inputs.
615#[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/// Failure to normalize a commit author or committer string.
625#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
626pub enum CommitIdentityError {
627    /// Newlines, carriage returns, or other C0 controls (except tab) in the input.
628    #[error("invalid identity: embedded control character")]
629    EmbeddedControl,
630    /// The string is not a contact form, bare email, or full header line with timestamp.
631    #[error("invalid identity: expected 'Name <email>', a bare email, or a full header line with timestamp")]
632    Unrecognized,
633    /// [`parse_author_arg`] failure for contact-style input.
634    #[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/// Replace the `<unix> <±HHMM>` suffix on a full or contact-style identity line.
644#[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
659/// Normalize author/committer input for writing a commit object.
660///
661/// Accepts either a full Git header line (`Name <email> <unix> <±HHMM>`) or a contact
662/// form (`Name <email>` / bare email). Contact forms are expanded with `now` and an
663/// optional `date_override` suffix (same rules as [`author_identity_line`]).
664///
665/// # Errors
666///
667/// Returns [`CommitIdentityError`] when the string is empty, contains embedded controls,
668/// or cannot be interpreted as a valid identity.
669pub 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
697/// Like [`prepare_commit_identity_line`], but preserves already-valid header bytes from an
698/// existing commit when `author_date` is not overridden.
699pub(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}