#![warn(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::indexing_slicing,
reason = "parses untrusted repository or network input (#103)"
)]
use crate::refs::reftable::LogValue;
use crate::{IdentityRef, ObjectFormat, ObjectId};
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ImportedRecord {
File {
format: ObjectFormat,
bytes: Vec<u8>,
},
Reftable {
update_index: u64,
value: LogValue,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ReflogFields<'a> {
pub old: ObjectId,
pub new: ObjectId,
pub name: &'a [u8],
pub email: &'a [u8],
pub seconds: i128,
pub offset_minutes: i16,
pub message: &'a [u8],
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ReflogInterpretationError {
#[error("incomplete reflog record")]
Incomplete,
#[error("malformed reflog object IDs")]
ObjectIds,
#[error("malformed reflog identity or date")]
Identity,
#[error("reflog date exceeds interpretation range")]
DateRange,
}
impl ImportedRecord {
pub fn recoverable_roots(&self) -> impl Iterator<Item = ObjectId> {
let ids = match self {
Self::File { format, bytes } => file_ids(*format, bytes),
Self::Reftable { value, .. } => [Some(value.old), Some(value.new)],
};
ids.into_iter()
.flatten()
.filter(|id| *id != ObjectId::null(id.format()))
}
pub fn fields(&self) -> Result<ReflogFields<'_>, ReflogInterpretationError> {
match self {
Self::File { format, bytes } => file_fields(*format, bytes),
Self::Reftable { value, .. } => Ok(ReflogFields {
old: value.old,
new: value.new,
name: &value.name,
email: &value.email,
seconds: i128::from(value.seconds),
offset_minutes: value.offset_minutes,
message: value.message.strip_suffix(b"\n").unwrap_or(&value.message),
}),
}
}
}
fn file_ids(format: ObjectFormat, bytes: &[u8]) -> [Option<ObjectId>; 2] {
let width = format.digest_len() * 2;
let id = |start| {
bytes
.get(start..start + width)
.and_then(|s| std::str::from_utf8(s).ok())
.and_then(|s| ObjectId::from_hex(format, s).ok())
};
[id(0), id(width + 1)]
}
fn file_fields(
format: ObjectFormat,
bytes: &[u8],
) -> Result<ReflogFields<'_>, ReflogInterpretationError> {
use ReflogInterpretationError as E;
let line = bytes.strip_suffix(b"\n").ok_or(E::Incomplete)?;
let width = format.digest_len() * 2;
let [Some(old), Some(new)] = file_ids(format, line) else {
return Err(E::ObjectIds);
};
if line.get(width) != Some(&b' ') || line.get(2 * width + 1) != Some(&b' ') {
return Err(E::ObjectIds);
}
let identity = line
.get(2 * width + 2..)
.and_then(|identity| IdentityRef::parse(identity).ok())
.ok_or(E::Identity)?;
let date = identity.date_bytes.strip_prefix(b" ").ok_or(E::Identity)?;
let (seconds, zone) = crate::bytes::split_once(date, b' ').ok_or(E::Identity)?;
let seconds = std::str::from_utf8(seconds).map_err(|_| E::Identity)?;
let digits = seconds.strip_prefix(['+', '-']).unwrap_or(seconds);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(E::Identity);
}
let seconds = seconds.parse::<i128>().map_err(|_| E::DateRange)?;
let Some((&[sign, digits @ ..], message)) = zone.split_first_chunk::<5>() else {
return Err(E::Identity);
};
if !matches!(sign, b'+' | b'-') || !digits.iter().all(u8::is_ascii_digit) {
return Err(E::Identity);
}
let hhmm = digits
.iter()
.fold(0_i16, |value, digit| value * 10 + i16::from(digit - b'0'));
let hhmm = if sign == b'-' { -hhmm } else { hhmm };
let message = message.strip_prefix(b"\t").unwrap_or(message);
Ok(ReflogFields {
old,
new,
name: identity.name,
email: identity.email,
seconds,
offset_minutes: hhmm / 100 * 60 + hhmm % 100,
message,
})
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn fields(tail: &[u8]) -> Result<(i16, Vec<u8>), ReflogInterpretationError> {
let mut bytes =
format!("{} {} ", ObjectId::Sha1([0; 20]), ObjectId::Sha1([1; 20])).into_bytes();
bytes.extend_from_slice(tail);
let record = ImportedRecord::File {
format: ObjectFormat::Sha1,
bytes,
};
record
.fields()
.map(|fields| (fields.offset_minutes, fields.message.to_vec()))
}
#[rstest]
#[case::east(b"A <a> 1 +0130\tm\n", 90, b"m")]
#[case::west(b"A <a> 1 -0130m\n", -90, b"m")]
#[case::maximum(b"A <a> 1 +9999\n", 6039, b"")]
fn interprets_four_digit_zones(
#[case] tail: &[u8],
#[case] offset: i16,
#[case] message: &[u8],
) {
assert_eq!(fields(tail), Ok((offset, message.to_vec())));
}
#[rstest]
#[case::sign_only(b"A <a> 1 +\n")]
#[case::short(b"A <a> 1 +013\n")]
#[case::text(b"A <a> 1 +01x0\n")]
#[case::unsigned(b"A <a> 1 01300\n")]
#[case::no_zone(b"A <a> 1\n")]
#[case::no_identity(b"\n")]
fn rejects_malformed_zones(#[case] tail: &[u8]) {
assert_eq!(fields(tail), Err(ReflogInterpretationError::Identity));
}
}