use super::{CommitError, Signature};
use crate::display::{DateFormat, FormattedDate};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct IdentityRef<'a> {
pub name: &'a [u8],
pub email: &'a [u8],
pub date_bytes: &'a [u8],
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct IdentityDate {
pub seconds: i64,
pub offset_minutes: i16,
}
impl IdentityDate {
#[must_use]
pub fn format(self, format: DateFormat) -> FormattedDate {
FormattedDate::new(self, format)
}
}
impl From<&Signature> for IdentityDate {
fn from(signature: &Signature) -> Self {
Self {
seconds: signature.seconds,
offset_minutes: signature.offset_minutes,
}
}
}
fn signed_decimal(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
let sign = usize::from(matches!(bytes.first(), Some(b'+' | b'-')));
let digits = bytes
.get(sign..)?
.iter()
.take_while(|b| b.is_ascii_digit())
.count();
(digits > 0).then(|| bytes.split_at_checked(sign + digits))?
}
impl<'a> IdentityRef<'a> {
pub fn parse(bytes: &'a [u8]) -> Result<Self, CommitError> {
let (name, rest) =
crate::bytes::split_once(bytes, b'<').ok_or(CommitError::InvalidSignature)?;
let (email, date_bytes) =
crate::bytes::split_once(rest, b'>').ok_or(CommitError::InvalidSignature)?;
Ok(Self {
name: name.trim_ascii_end(),
email,
date_bytes,
})
}
pub fn date(self) -> Result<IdentityDate, CommitError> {
self.date_parts().map(|(date, _)| date)
}
fn date_parts(self) -> Result<(IdentityDate, i32), CommitError> {
let bytes = self.date_bytes.trim_ascii_start();
let (seconds, zone) = signed_decimal(bytes).ok_or(CommitError::InvalidDate)?;
let seconds = std::str::from_utf8(seconds)
.ok()
.and_then(|s| s.parse().ok())
.ok_or(CommitError::InvalidDate)?;
let zone = zone.trim_ascii_start();
if !matches!(zone.first(), Some(b'+' | b'-')) {
return Err(CommitError::InvalidDate);
}
let (hhmm, _) = signed_decimal(zone).ok_or(CommitError::InvalidDate)?;
let hhmm: i32 = std::str::from_utf8(hhmm)
.ok()
.and_then(|s| s.parse().ok())
.ok_or(CommitError::InvalidDate)?;
let minutes = (hhmm / 100)
.checked_mul(60)
.and_then(|v| v.checked_add(hhmm % 100))
.and_then(|v| i16::try_from(v).ok())
.ok_or(CommitError::InvalidDate)?;
Ok((
IdentityDate {
seconds,
offset_minutes: minutes,
},
hhmm,
))
}
pub fn format_date(self, format: DateFormat) -> Result<FormattedDate, CommitError> {
if matches!(
self.date_bytes.trim_ascii_start().first(),
Some(b'+' | b'-')
) {
return Err(CommitError::InvalidDate);
}
let (date, hhmm) = self.date_parts()?;
let local = i128::from(date.seconds) + i128::from(date.offset_minutes) * 60;
if local < 0 {
return Err(CommitError::InvalidDate);
}
Ok(FormattedDate::new(date, format).with_recorded_zone(hhmm))
}
pub fn signature(self) -> Result<Signature, CommitError> {
let date = self.date()?;
Ok(Signature {
name: self.name.to_vec(),
email: self.email.to_vec(),
seconds: date.seconds,
offset_minutes: date.offset_minutes,
})
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::west_of_epoch(b"0 -0001")]
#[case::one_second_before(b"59 -0001")]
#[case::negative_instant(b"-1 +0000")]
#[case::negative_instant_positive_local(b"-60 +0100")]
#[case::plus_seconds(b"+5 +0000")]
#[case::offset_overflow(b"1 +60000")]
#[case::malformed(b"unknown")]
fn refuses_unformattable_imported_dates(#[case] bytes: &[u8]) {
let identity = IdentityRef {
name: b"A",
email: b"a",
date_bytes: bytes,
};
assert!(matches!(
identity.format_date(DateFormat::Default),
Err(CommitError::InvalidDate)
));
assert!(matches!(
identity.format_date(DateFormat::Iso),
Err(CommitError::InvalidDate)
));
assert_eq!(identity.date_bytes, bytes);
}
#[rstest]
#[case::pre_epoch(b"-1 +0000", -1, 0)]
#[case::minimum(b"-9223372036854775808 -2359", i64::MIN, -1439)]
#[case::maximum(b"9223372036854775807 +2359", i64::MAX, 1439)]
#[case::padded(b"+00042 -0000", 42, 0)]
#[case::tabs(b"\t1\t+0100", 1, 60)]
#[case::short(b"1 +1", 1, 1)]
#[case::hours(b"1 +2400", 1, 1440)]
#[case::minutes(b"1 -0060", 1, -60)]
#[case::trailing(b"1 +0000 text", 1, 0)]
#[case::suffix(b"1 +01foo", 1, 1)]
#[case::adjacent(b"1+0100", 1, 60)]
fn interprets_numbers_without_construction_policy(
#[case] bytes: &[u8],
#[case] seconds: i64,
#[case] offset_minutes: i16,
) {
let identity = IdentityRef {
name: b"A",
email: b"a",
date_bytes: bytes,
};
assert_eq!(
identity.date(),
Ok(IdentityDate {
seconds,
offset_minutes
})
);
}
#[rstest]
#[case::empty(b"")]
#[case::missing_zone(b"1")]
#[case::overflow(b"9223372036854775808 +0000")]
#[case::underflow(b"-9223372036854775809 +0000")]
#[case::text(b"now +0000")]
#[case::unsigned(b"1 0000")]
#[case::zone_text(b"1 +x000")]
#[case::zone_overflow(b"1 +99999999999999999999999")]
#[case::sign_only(b"-")]
#[case::sign_before_zone(b"- +0000")]
#[case::zone_sign_only(b"1 +")]
#[case::zone_sign_at_end(b"1 -")]
fn preserves_bytes_after_interpretation_error(#[case] bytes: &[u8]) {
let input = [b"A\xff<a\xfe>".as_slice(), bytes].concat();
let identity = IdentityRef::parse(&input).unwrap();
assert_eq!(identity.date(), Err(CommitError::InvalidDate));
assert_eq!(
(identity.name, identity.email, identity.date_bytes),
(b"A\xff".as_slice(), b"a\xfe".as_slice(), bytes)
);
}
#[rstest]
#[case::no_brackets(b"A a 1 +0000".as_slice())]
#[case::unclosed(b"A <a 1 +0000")]
#[case::open_at_end(b"A <")]
#[case::close_before_open(b"A >a< 1 +0000")]
fn rejects_identity_without_brackets(#[case] bytes: &[u8]) {
assert_eq!(
IdentityRef::parse(bytes),
Err(CommitError::InvalidSignature)
);
}
}