use crate::{HashKind, ObjectId, Result, error::invalid};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectKind {
Commit,
Tree,
Blob,
Tag,
}
impl ObjectKind {
pub(crate) fn parse(value: &[u8]) -> Result<Self> {
match value {
b"commit" => Ok(Self::Commit),
b"tree" => Ok(Self::Tree),
b"blob" => Ok(Self::Blob),
b"tag" => Ok(Self::Tag),
_ => Err(invalid(format!(
"unknown object kind {}",
String::from_utf8_lossy(value)
))),
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Commit => "commit",
Self::Tree => "tree",
Self::Blob => "blob",
Self::Tag => "tag",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Object {
pub id: ObjectId,
pub kind: ObjectKind,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signature {
pub name: String,
pub email: String,
pub timestamp: i64,
pub timezone_minutes: i16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
pub id: ObjectId,
pub tree: ObjectId,
pub parents: Vec<ObjectId>,
pub author: Option<Signature>,
pub committer: Option<Signature>,
pub encoding: Option<String>,
pub message: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitMetadata {
pub id: ObjectId,
pub tree: ObjectId,
pub parents: Vec<ObjectId>,
pub committer_time: i64,
}
impl Commit {
pub(crate) fn parse(id: ObjectId, data: &[u8], max_parents: usize) -> Result<Self> {
let (headers, message) = split_headers(data)?;
let mut tree = None;
let mut parents = Vec::new();
let mut author = None;
let mut committer = None;
let mut encoding = None;
for (name, value) in headers {
match name {
b"tree" => tree = Some(parse_oid(value, id.kind())?),
b"parent" => {
if parents.len() >= max_parents {
return Err(crate::GitError::LimitExceeded {
resource: "commit parents",
limit: max_parents,
});
}
parents.push(parse_oid(value, id.kind())?);
}
b"author" => author = parse_signature(value),
b"committer" => committer = parse_signature(value),
b"encoding" => encoding = Some(String::from_utf8_lossy(value).into_owned()),
_ => {}
}
}
Ok(Self {
id,
tree: tree.ok_or_else(|| invalid("commit has no tree header"))?,
parents,
author,
committer,
encoding,
message: message.to_vec(),
})
}
#[must_use]
pub fn message_lossy(&self) -> String {
String::from_utf8_lossy(&self.message).into_owned()
}
#[must_use]
pub fn summary_lossy(&self) -> String {
let message = String::from_utf8_lossy(&self.message);
message.lines().next().unwrap_or_default().to_owned()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tag {
pub id: ObjectId,
pub target: ObjectId,
pub target_kind: ObjectKind,
pub name: String,
pub tagger: Option<Signature>,
pub message: Vec<u8>,
}
impl Tag {
pub(crate) fn parse(id: ObjectId, data: &[u8]) -> Result<Self> {
let (headers, message) = split_headers(data)?;
let mut target = None;
let mut target_kind = None;
let mut name = None;
let mut tagger = None;
for (key, value) in headers {
match key {
b"object" => target = Some(parse_oid(value, id.kind())?),
b"type" => target_kind = Some(ObjectKind::parse(value)?),
b"tag" => name = Some(String::from_utf8_lossy(value).into_owned()),
b"tagger" => tagger = parse_signature(value),
_ => {}
}
}
Ok(Self {
id,
target: target.ok_or_else(|| invalid("tag has no object header"))?,
target_kind: target_kind.ok_or_else(|| invalid("tag has no type header"))?,
name: name.ok_or_else(|| invalid("tag has no tag header"))?,
tagger,
message: message.to_vec(),
})
}
}
type Headers<'a> = Vec<(&'a [u8], &'a [u8])>;
fn split_headers(data: &[u8]) -> Result<(Headers<'_>, &[u8])> {
let separator = data
.windows(2)
.position(|value| value == b"\n\n")
.ok_or_else(|| invalid("object headers are not terminated"))?;
let mut headers = Vec::new();
for line in data[..separator].split(|byte| *byte == b'\n') {
if line.first() == Some(&b' ') {
continue;
}
let split = line
.iter()
.position(|byte| *byte == b' ')
.ok_or_else(|| invalid("object header has no value"))?;
headers.push((&line[..split], &line[split + 1..]));
}
Ok((headers, &data[separator + 2..]))
}
fn parse_oid(value: &[u8], kind: HashKind) -> Result<ObjectId> {
ObjectId::from_hex_for(
std::str::from_utf8(value).map_err(|_| invalid("object id is not ASCII"))?,
kind,
)
}
pub(crate) fn parse_signature(value: &[u8]) -> Option<Signature> {
let text = std::str::from_utf8(value).ok()?;
let email_start = text.rfind(" <")?;
let email_end = text[email_start + 2..]
.find("> ")?
.saturating_add(email_start + 2);
let mut tail = text[email_end + 2..].split_ascii_whitespace();
let timestamp = tail.next()?.parse().ok()?;
let timezone = tail.next()?;
if timezone.len() != 5 {
return None;
}
let sign = if timezone.starts_with('-') { -1 } else { 1 };
let hours: i16 = timezone[1..3].parse().ok()?;
let minutes: i16 = timezone[3..5].parse().ok()?;
Some(Signature {
name: text[..email_start].to_owned(),
email: text[email_start + 2..email_end].to_owned(),
timestamp,
timezone_minutes: sign * (hours * 60 + minutes),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_commit_headers_and_signature() {
let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
let tree = "2222222222222222222222222222222222222222";
let raw = format!(
"tree {tree}\nauthor Ada <ada@example.com> 42 +0230\ncommitter Ada <ada@example.com> 43 +0230\n\nsubject\nbody\n"
);
let commit = Commit::parse(id, raw.as_bytes(), 16).unwrap();
assert_eq!(commit.tree.to_string(), tree);
assert_eq!(commit.summary_lossy(), "subject");
assert_eq!(commit.author.unwrap().timezone_minutes, 150);
}
#[test]
fn parses_parents_encoding_and_annotated_tag() {
let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
let target = "2222222222222222222222222222222222222222";
let raw = format!(
"tree {target}\nparent {target}\nencoding ISO-8859-1\n\
author Invalid\ncommitter Ada <ada@example.com> 43 -0130\n\nmessage"
);
let commit = Commit::parse(id, raw.as_bytes(), 2).unwrap();
assert_eq!(commit.parents.len(), 1);
assert_eq!(commit.encoding.as_deref(), Some("ISO-8859-1"));
assert!(commit.author.is_none());
assert_eq!(commit.committer.as_ref().unwrap().timezone_minutes, -90);
assert_eq!(commit.message_lossy(), "message");
let raw = format!(
"object {target}\ntype blob\ntag v1.0\n\
tagger Ada <ada@example.com> 44 +0000\n\nrelease"
);
let tag = Tag::parse(id, raw.as_bytes()).unwrap();
assert_eq!(tag.target.to_string(), target);
assert_eq!(tag.target_kind, ObjectKind::Blob);
assert_eq!(tag.name, "v1.0");
assert_eq!(tag.message, b"release");
}
#[test]
fn rejects_malformed_objects_and_parent_limit() {
let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
assert!(Commit::parse(id, b"author x\n\nmessage", 1).is_err());
let target = "2222222222222222222222222222222222222222";
let raw = format!("tree {target}\nparent {target}\nparent {target}\n\nmessage");
assert!(Commit::parse(id, raw.as_bytes(), 1).is_err());
assert!(Tag::parse(id, b"object bad\n\nmessage").is_err());
assert!(ObjectKind::parse(b"unknown").is_err());
assert_eq!(ObjectKind::Tree.as_str(), "tree");
assert_eq!(ObjectKind::Tag.as_str(), "tag");
}
}