use crate::commit::assemble_identity;
use crate::git_date::tm::date_overflows;
use crate::objects::{HashAlgo, ObjectKind};
use time::format_description::well_known::Rfc3339;
use time::{OffsetDateTime, UtcOffset};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignatureTimestamp {
Valid(i64),
Sentinel,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signature {
pub name: String,
pub email: String,
pub time: OffsetDateTime,
}
impl Signature {
#[must_use]
pub fn format_rfc3339(&self) -> Option<String> {
self.time.format(&Rfc3339).ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedSignatureTimes {
pub unix_seconds: i64,
pub tz_offset_secs: i64,
pub tz_hhmm_range: std::ops::Range<usize>,
}
fn scan_decimal_timestamp(bytes: &[u8], mut i: usize) -> Option<(u128, usize)> {
const MAX_DIGITS: usize = 21;
let start = i;
let mut count = 0usize;
while i < bytes.len() && bytes[i].is_ascii_digit() {
count += 1;
if count > MAX_DIGITS {
return Some((u128::MAX, i));
}
i += 1;
}
if count == 0 {
return None;
}
let s = std::str::from_utf8(&bytes[start..i]).ok()?;
let v: u128 = s.parse().ok()?;
Some((v, i))
}
fn skip_fsck_date_leading_ws(bytes: &[u8], mut i: usize) -> usize {
while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
i += 1;
}
i
}
fn parse_tz_hhmm_offset(offset: &str) -> Option<i64> {
let b = offset.as_bytes();
if b.len() < 5 {
return None;
}
if !(b[0] == b'+' || b[0] == b'-') {
return None;
}
let sign = if b[0] == b'-' { -1i64 } else { 1i64 };
let hours: i64 = std::str::from_utf8(&b[1..3]).ok()?.parse().ok()?;
let minutes: i64 = std::str::from_utf8(&b[3..5]).ok()?.parse().ok()?;
Some(sign * (hours * 3600 + minutes * 60))
}
#[must_use]
pub fn parse_signature_times(ident: &str) -> Option<ParsedSignatureTimes> {
match parse_signature_tail(ident)? {
SignatureTail::Valid(p) => Some(p),
SignatureTail::Overflow | SignatureTail::NonNumeric => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureTail {
Valid(ParsedSignatureTimes),
Overflow,
NonNumeric,
}
#[must_use]
pub fn parse_signature_tail(ident: &str) -> Option<SignatureTail> {
let bytes = ident.as_bytes();
let gt = ident.rfind('>')?;
let mut i = skip_fsck_date_leading_ws(bytes, gt + 1);
if i >= bytes.len() || !bytes[i].is_ascii_digit() {
return Some(SignatureTail::NonNumeric);
}
let (raw, after_digits) = scan_decimal_timestamp(bytes, i)?;
if after_digits >= bytes.len() || bytes[after_digits] != b' ' {
return None;
}
i = after_digits + 1;
if i + 5 > bytes.len() {
return None;
}
let tz_slice = ident.get(i..i + 5)?;
let tz_offset_secs = parse_tz_hhmm_offset(tz_slice)?;
let tz_hhmm_range = i..i + 5;
if raw == u128::MAX || raw > u64::MAX as u128 || date_overflows(raw as u64) {
return Some(SignatureTail::Overflow);
}
let unix_seconds = i64::try_from(raw).ok()?;
Some(SignatureTail::Valid(ParsedSignatureTimes {
unix_seconds,
tz_offset_secs,
tz_hhmm_range,
}))
}
#[must_use]
pub fn parse_signature(ident: &str) -> Option<Signature> {
let ParsedSignatureTimes {
unix_seconds,
tz_offset_secs,
..
} = match parse_signature_tail(ident)? {
SignatureTail::Valid(p) => p,
SignatureTail::Overflow | SignatureTail::NonNumeric => return None,
};
let (name, email) = parse_identity_name_email(ident)?;
let offset_secs = i32::try_from(tz_offset_secs).ok()?;
let offset = UtcOffset::from_whole_seconds(offset_secs).ok()?;
let utc = OffsetDateTime::from_unix_timestamp(unix_seconds).ok()?;
Some(Signature {
name,
email,
time: utc.to_offset(offset),
})
}
fn parse_identity_name_email(ident: &str) -> Option<(String, String)> {
let lt = ident.find('<')?;
let name = ident[..lt].trim_end().to_owned();
let rest = &ident[lt + 1..];
let gt = rest.find('>')?;
let email = rest[..gt].trim().to_owned();
Some((name, email))
}
pub fn signature_timestamp_for_pretty(ident: &str) -> SignatureTimestamp {
match parse_signature_times(ident) {
Some(p) => SignatureTimestamp::Valid(p.unix_seconds),
None => SignatureTimestamp::Sentinel,
}
}
#[must_use]
pub fn committer_timestamp_for_until_filter(ident: &str) -> i64 {
match signature_timestamp_for_pretty(ident) {
SignatureTimestamp::Valid(ts) => ts,
SignatureTimestamp::Sentinel => 0,
}
}
#[must_use]
pub fn timestamp_for_at_ct(ts: SignatureTimestamp) -> Option<i64> {
match ts {
SignatureTimestamp::Valid(v) => Some(v),
SignatureTimestamp::Sentinel => None,
}
}
pub fn fsck_commit_idents(data: &[u8], hash_algo: HashAlgo) -> Result<(), String> {
crate::fsck_standalone::fsck_object(
ObjectKind::Commit,
data,
crate::fsck_standalone::FsckObjectOptions::new(hash_algo),
)
.map_err(|e| e.report_line())
}
#[must_use]
pub fn committer_unix_seconds_for_ordering(ident: &str) -> i64 {
match signature_timestamp_for_pretty(ident) {
SignatureTimestamp::Valid(ts) => ts,
SignatureTimestamp::Sentinel => 0,
}
}
#[must_use]
pub fn committer_unix_seconds_for_ordering_bytes(ident: &[u8]) -> i64 {
let Ok(ident) = std::str::from_utf8(ident) else {
return 0;
};
committer_unix_seconds_for_ordering(ident)
}
#[must_use]
pub fn committer_timestamp_for_until_filter_bytes(ident: &[u8]) -> i64 {
let Ok(ident) = std::str::from_utf8(ident) else {
return 0;
};
committer_timestamp_for_until_filter(ident)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorContact {
pub name: String,
pub email: String,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AuthorArgError {
#[error("invalid author: expected 'Name <email>' or a bare email address")]
InvalidFormat,
#[error("invalid author: empty email")]
EmptyEmail,
#[error("invalid author: trailing text after '>'")]
TrailingText,
}
pub fn parse_author_arg(raw: &str) -> Result<AuthorContact, AuthorArgError> {
let s = raw.trim();
if s.is_empty() {
return Err(AuthorArgError::InvalidFormat);
}
if s.contains('\n') || s.contains('\r') {
return Err(AuthorArgError::InvalidFormat);
}
if let Some(lt) = s.find('<') {
let rest = &s[lt + 1..];
let gt = rest.find('>').ok_or(AuthorArgError::InvalidFormat)?;
let email = &rest[..gt];
if email.is_empty() {
return Err(AuthorArgError::EmptyEmail);
}
let after = rest[gt + 1..].trim();
if !after.is_empty() {
return Err(AuthorArgError::TrailingText);
}
let name = s[..lt].trim_end_matches(|c: char| c.is_ascii_whitespace());
return Ok(AuthorContact {
name: name.to_owned(),
email: email.to_owned(),
});
}
if s.contains('@') && !s.chars().any(char::is_whitespace) {
return Ok(AuthorContact {
name: String::new(),
email: s.to_owned(),
});
}
Err(AuthorArgError::InvalidFormat)
}
#[must_use]
pub fn author_identity_line(
contact: &AuthorContact,
date_override: Option<&str>,
now: OffsetDateTime,
) -> String {
assemble_identity(&contact.name, &contact.email, date_override, now)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_numeric_author_date_is_non_numeric_tail() {
let ident = "A <e@x> totally_bogus -0700";
assert!(matches!(
parse_signature_tail(ident),
Some(SignatureTail::NonNumeric)
));
}
#[test]
fn u128_max_digit_count_is_overflow_tail() {
let ident = "A <e@x> 18446744073709551617 -0700";
assert!(matches!(
parse_signature_tail(ident),
Some(SignatureTail::Overflow)
));
}
#[test]
fn parse_author_arg_name_and_email() {
let c = parse_author_arg("A U Thor <author@example.com>").expect("parse");
assert_eq!(c.name, "A U Thor");
assert_eq!(c.email, "author@example.com");
}
#[test]
fn parse_author_arg_bare_email() {
let c = parse_author_arg("nobody@example.com").expect("parse");
assert!(c.name.is_empty());
assert_eq!(c.email, "nobody@example.com");
}
#[test]
fn parse_author_arg_rejects_empty_email_in_brackets() {
assert_eq!(parse_author_arg("Name <>"), Err(AuthorArgError::EmptyEmail));
}
#[test]
fn parse_author_arg_rejects_trailing_garbage() {
assert_eq!(
parse_author_arg("A <a@b.com> extra"),
Err(AuthorArgError::TrailingText)
);
}
#[test]
fn parse_author_arg_rejects_embedded_newline() {
assert_eq!(
parse_author_arg("Bad\nInjected <x@y.example>"),
Err(AuthorArgError::InvalidFormat)
);
}
#[test]
fn parse_signature_preserves_positive_offset() {
let ident = "Ada Lovelace <ada@example.com> 1700000000 +0530";
let sig = parse_signature(ident).expect("parse");
assert_eq!(sig.name, "Ada Lovelace");
assert_eq!(sig.email, "ada@example.com");
assert_eq!(
sig.format_rfc3339().as_deref(),
Some("2023-11-15T03:43:20+05:30")
);
}
#[test]
fn parse_signature_preserves_negative_offset() {
let ident = "Ada Lovelace <ada@example.com> 1700000000 -0800";
let sig = parse_signature(ident).expect("parse");
assert_eq!(
sig.format_rfc3339().as_deref(),
Some("2023-11-14T14:13:20-08:00")
);
}
#[test]
fn parse_signature_malformed_identity_is_none() {
assert!(parse_signature("A <e@x> totally_bogus -0700").is_none());
assert!(parse_signature("not an identity").is_none());
}
}