use sley_core::{GitError, ObjectFormat, ObjectId, Result, Signature};
use std::str::FromStr;
pub use sley_core::BString;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ObjectType {
Blob,
Tree,
Commit,
Tag,
}
impl ObjectType {
pub const fn as_str(self) -> &'static str {
match self {
Self::Blob => "blob",
Self::Tree => "tree",
Self::Commit => "commit",
Self::Tag => "tag",
}
}
}
impl FromStr for ObjectType {
type Err = GitError;
fn from_str(value: &str) -> Result<Self> {
match value {
"blob" => Ok(Self::Blob),
"tree" => Ok(Self::Tree),
"commit" => Ok(Self::Commit),
"tag" => Ok(Self::Tag),
other => Err(GitError::InvalidObject(format!(
"unknown object type {other}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncodedObject {
pub object_type: ObjectType,
pub body: Vec<u8>,
}
impl EncodedObject {
pub fn new(object_type: ObjectType, body: impl Into<Vec<u8>>) -> Self {
Self {
object_type,
body: body.into(),
}
}
pub fn framed_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.body.len() + 32);
out.extend_from_slice(self.object_type.as_str().as_bytes());
out.push(b' ');
out.extend_from_slice(self.body.len().to_string().as_bytes());
out.push(0);
out.extend_from_slice(&self.body);
out
}
pub fn object_id(&self, format: ObjectFormat) -> Result<ObjectId> {
sley_core::object_id_for_bytes(format, self.object_type.as_str(), &self.body)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tree {
pub entries: Vec<TreeEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub mode: u32,
pub name: BString,
pub oid: ObjectId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntryRef<'a> {
pub mode: u32,
pub name: &'a [u8],
pub oid: ObjectId,
}
#[derive(Debug, Clone)]
pub struct TreeEntries<'a> {
format: ObjectFormat,
bytes: &'a [u8],
offset: usize,
}
impl<'a> TreeEntries<'a> {
pub const fn new(format: ObjectFormat, bytes: &'a [u8]) -> Self {
Self {
format,
bytes,
offset: 0,
}
}
}
impl<'a> Iterator for TreeEntries<'a> {
type Item = Result<TreeEntryRef<'a>>;
fn next(&mut self) -> Option<Self::Item> {
if self.offset >= self.bytes.len() {
return None;
}
match parse_tree_entry_ref(self.format, self.bytes, self.offset) {
Ok((entry, next_offset)) => {
self.offset = next_offset;
Some(Ok(entry))
}
Err(err) => {
self.offset = self.bytes.len();
Some(Err(err))
}
}
}
}
impl<'a> From<TreeEntryRef<'a>> for TreeEntry {
fn from(entry: TreeEntryRef<'a>) -> Self {
Self {
mode: entry.mode,
name: entry.name.into(),
oid: entry.oid,
}
}
}
impl Tree {
pub fn parse(format: ObjectFormat, bytes: &[u8]) -> Result<Self> {
let entries = TreeEntries::new(format, bytes)
.map(|entry| entry.map(TreeEntry::from))
.collect::<Result<Vec<_>>>()?;
Ok(Self { entries })
}
pub fn write(&self) -> Vec<u8> {
let mut out = Vec::new();
for entry in &self.entries {
out.extend_from_slice(format!("{:o}", entry.mode).as_bytes());
out.push(b' ');
out.extend_from_slice(entry.name.as_bytes());
out.push(0);
out.extend_from_slice(entry.oid.as_bytes());
}
out
}
}
fn parse_tree_entry_ref<'a>(
format: ObjectFormat,
bytes: &'a [u8],
offset: usize,
) -> Result<(TreeEntryRef<'a>, usize)> {
let mode_end = bytes[offset..]
.iter()
.position(|byte| *byte == b' ')
.map(|relative| offset + relative)
.ok_or_else(|| GitError::InvalidFormat("unterminated tree mode".into()))?;
let mode_text = std::str::from_utf8(&bytes[offset..mode_end])
.map_err(|err| GitError::InvalidFormat(err.to_string()))?;
let mode = u32::from_str_radix(mode_text, 8)
.map_err(|_| GitError::InvalidFormat("invalid tree mode".into()))?;
let name_start = mode_end + 1;
let name_end = bytes[name_start..]
.iter()
.position(|byte| *byte == 0)
.map(|relative| name_start + relative)
.ok_or_else(|| GitError::InvalidFormat("unterminated tree path".into()))?;
if name_end == name_start {
return Err(GitError::InvalidFormat("empty tree path".into()));
}
let oid_start = name_end + 1;
let oid_end = oid_start
.checked_add(format.raw_len())
.ok_or_else(|| GitError::InvalidFormat("tree oid overflow".into()))?;
if oid_end > bytes.len() {
return Err(GitError::InvalidFormat("truncated tree object id".into()));
}
Ok((
TreeEntryRef {
mode,
name: &bytes[name_start..name_end],
oid: ObjectId::from_raw(format, &bytes[oid_start..oid_end])?,
},
oid_end,
))
}
pub fn tree_entry_object_type(mode: u32) -> ObjectType {
match mode {
0o040000 => ObjectType::Tree,
0o160000 => ObjectType::Commit,
_ => ObjectType::Blob,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntryKind {
Tree,
Blob,
BlobExecutable,
Symlink,
Commit,
}
impl EntryKind {
pub const fn mode(self) -> u32 {
match self {
Self::Tree => 0o040000,
Self::Blob => 0o100644,
Self::BlobExecutable => 0o100755,
Self::Symlink => 0o120000,
Self::Commit => 0o160000,
}
}
pub const fn from_mode(mode: u32) -> Option<Self> {
match mode {
0o040000 => Some(Self::Tree),
0o100644 => Some(Self::Blob),
0o100755 => Some(Self::BlobExecutable),
0o120000 => Some(Self::Symlink),
0o160000 => Some(Self::Commit),
_ => None,
}
}
pub const fn object_type(self) -> ObjectType {
match self {
Self::Tree => ObjectType::Tree,
Self::Commit => ObjectType::Commit,
_ => ObjectType::Blob,
}
}
}
impl From<EntryKind> for u32 {
fn from(kind: EntryKind) -> Self {
kind.mode()
}
}
impl TreeEntry {
pub fn kind(&self) -> Option<EntryKind> {
EntryKind::from_mode(self.mode)
}
pub fn is_tree(&self) -> bool {
self.mode == EntryKind::Tree.mode()
}
pub fn is_symlink(&self) -> bool {
self.mode == EntryKind::Symlink.mode()
}
pub fn is_gitlink(&self) -> bool {
self.mode == EntryKind::Commit.mode()
}
pub fn is_executable(&self) -> bool {
self.mode == EntryKind::BlobExecutable.mode()
}
}
impl TreeEntryRef<'_> {
pub fn kind(&self) -> Option<EntryKind> {
EntryKind::from_mode(self.mode)
}
pub fn is_tree(&self) -> bool {
self.mode == EntryKind::Tree.mode()
}
pub fn is_symlink(&self) -> bool {
self.mode == EntryKind::Symlink.mode()
}
pub fn is_gitlink(&self) -> bool {
self.mode == EntryKind::Commit.mode()
}
pub fn is_executable(&self) -> bool {
self.mode == EntryKind::BlobExecutable.mode()
}
pub fn to_owned(&self) -> TreeEntry {
TreeEntry {
mode: self.mode,
name: self.name.into(),
oid: self.oid,
}
}
}
pub fn tree_entry_cmp(
left_name: &[u8],
left_mode: u32,
right_name: &[u8],
right_mode: u32,
) -> std::cmp::Ordering {
use std::cmp::Ordering;
let shared = left_name.len().min(right_name.len());
let name_order = left_name[..shared].cmp(&right_name[..shared]);
if name_order != Ordering::Equal {
return name_order;
}
let left_end = left_name.len() == shared;
let right_end = right_name.len() == shared;
match (left_end, right_end) {
(true, true) => Ordering::Equal,
(true, false) => tree_name_terminator(left_mode).cmp(&right_name[shared]),
(false, true) => left_name[shared].cmp(&tree_name_terminator(right_mode)),
(false, false) => Ordering::Equal,
}
}
fn tree_name_terminator(mode: u32) -> u8 {
if mode == 0o040000 { b'/' } else { 0 }
}
#[derive(Debug, Clone, Default)]
pub struct TreeBuilder {
entries: Vec<TreeEntry>,
}
impl TreeBuilder {
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn from_tree(tree: Tree) -> Self {
Self {
entries: tree.entries,
}
}
pub fn upsert(&mut self, name: impl Into<BString>, kind: EntryKind, oid: ObjectId) {
self.upsert_raw(name, kind.mode(), oid);
}
pub fn upsert_raw(&mut self, name: impl Into<BString>, mode: u32, oid: ObjectId) {
let name = name.into();
if let Some(entry) = self
.entries
.iter_mut()
.find(|entry| entry.name == name.as_bytes())
{
entry.mode = mode;
entry.oid = oid;
} else {
self.entries.push(TreeEntry { mode, name, oid });
}
}
pub fn remove(&mut self, name: &[u8]) -> bool {
if let Some(position) = self.entries.iter().position(|entry| entry.name == name) {
self.entries.swap_remove(position);
true
} else {
false
}
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn build(self) -> Tree {
let mut entries = self.entries;
entries.sort_by(|left, right| {
tree_entry_cmp(
left.name.as_bytes(),
left.mode,
right.name.as_bytes(),
right.mode,
)
});
Tree { entries }
}
pub fn write(self) -> Vec<u8> {
self.build().write()
}
pub fn object_id(self, format: ObjectFormat) -> Result<ObjectId> {
EncodedObject::new(ObjectType::Tree, self.write()).object_id(format)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
pub tree: ObjectId,
pub parents: Vec<ObjectId>,
pub author: Vec<u8>,
pub committer: Vec<u8>,
pub encoding: Option<Vec<u8>>,
pub message: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitRef<'a> {
pub tree: ObjectId,
pub parents: Vec<ObjectId>,
pub author: &'a [u8],
pub committer: &'a [u8],
pub encoding: Option<&'a [u8]>,
pub message: &'a [u8],
}
impl Commit {
pub fn parse(format: ObjectFormat, bytes: &[u8]) -> Result<Self> {
Ok(Self::parse_ref(format, bytes)?.into())
}
pub fn parse_ref<'a>(format: ObjectFormat, bytes: &'a [u8]) -> Result<CommitRef<'a>> {
CommitRef::parse(format, bytes)
}
pub fn write(&self) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(format!("tree {}\n", self.tree).as_bytes());
for parent in &self.parents {
out.extend_from_slice(format!("parent {parent}\n").as_bytes());
}
out.extend_from_slice(b"author ");
out.extend_from_slice(&self.author);
out.push(b'\n');
out.extend_from_slice(b"committer ");
out.extend_from_slice(&self.committer);
if let Some(encoding) = &self.encoding {
out.extend_from_slice(b"\nencoding ");
out.extend_from_slice(encoding);
}
out.extend_from_slice(b"\n\n");
out.extend_from_slice(&self.message);
out
}
pub fn author_signature(&self) -> Option<Signature> {
Signature::from_ident_line(&self.author)
}
pub fn committer_signature(&self) -> Option<Signature> {
Signature::from_ident_line(&self.committer)
}
}
impl<'a> CommitRef<'a> {
pub fn parse(format: ObjectFormat, bytes: &'a [u8]) -> Result<Self> {
let split = bytes
.windows(2)
.position(|window| window == b"\n\n")
.ok_or_else(|| GitError::InvalidObject("commit missing message separator".into()))?;
let mut tree = None;
let mut parents = Vec::new();
let mut author = None;
let mut committer = None;
let mut encoding = None;
for line in bytes[..split].split(|byte| *byte == b'\n') {
if let Some(value) = line.strip_prefix(b"tree ") {
tree = Some(ObjectId::from_hex(format, ascii_header_value(value)?)?);
} else if let Some(value) = line.strip_prefix(b"parent ") {
parents.push(ObjectId::from_hex(format, ascii_header_value(value)?)?);
} else if let Some(value) = line.strip_prefix(b"author ") {
author = Some(value);
} else if let Some(value) = line.strip_prefix(b"committer ") {
committer = Some(value);
} else if let Some(value) = line.strip_prefix(b"encoding ") {
encoding = Some(value);
}
}
Ok(Self {
tree: tree.ok_or_else(|| GitError::InvalidObject("commit missing tree".into()))?,
parents,
author: author
.ok_or_else(|| GitError::InvalidObject("commit missing author".into()))?,
committer: committer
.ok_or_else(|| GitError::InvalidObject("commit missing committer".into()))?,
encoding,
message: &bytes[split + 2..],
})
}
pub fn to_owned(&self) -> Commit {
Commit {
tree: self.tree,
parents: self.parents.clone(),
author: self.author.to_vec(),
committer: self.committer.to_vec(),
encoding: self.encoding.map(<[u8]>::to_vec),
message: self.message.to_vec(),
}
}
pub fn author_signature(&self) -> Option<Signature> {
Signature::from_ident_line(self.author)
}
pub fn committer_signature(&self) -> Option<Signature> {
Signature::from_ident_line(self.committer)
}
}
impl<'a> From<CommitRef<'a>> for Commit {
fn from(commit: CommitRef<'a>) -> Self {
Self {
tree: commit.tree,
parents: commit.parents,
author: commit.author.to_vec(),
committer: commit.committer.to_vec(),
encoding: commit.encoding.map(<[u8]>::to_vec),
message: commit.message.to_vec(),
}
}
}
#[derive(Debug, Clone, Eq)]
pub struct Tag {
pub object: ObjectId,
pub object_type: ObjectType,
pub name: Vec<u8>,
pub tagger: Option<Vec<u8>>,
pub message: Vec<u8>,
pub raw_body: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TagRef<'a> {
pub object: ObjectId,
pub object_type: ObjectType,
pub name: &'a [u8],
pub tagger: Option<&'a [u8]>,
pub message: &'a [u8],
pub raw_body: Option<&'a [u8]>,
}
impl PartialEq for Tag {
fn eq(&self, other: &Self) -> bool {
self.object == other.object
&& self.object_type == other.object_type
&& self.name == other.name
&& self.tagger == other.tagger
&& self.message == other.message
}
}
impl Tag {
pub fn parse(format: ObjectFormat, bytes: &[u8]) -> Result<Self> {
Ok(Self::parse_ref(format, bytes)?.into())
}
pub fn parse_ref<'a>(format: ObjectFormat, bytes: &'a [u8]) -> Result<TagRef<'a>> {
TagRef::parse(format, bytes)
}
pub fn write(&self) -> Vec<u8> {
if let Some(raw) = &self.raw_body {
return raw.clone();
}
let mut out = Vec::new();
out.extend_from_slice(format!("object {}\n", self.object).as_bytes());
out.extend_from_slice(format!("type {}\n", self.object_type.as_str()).as_bytes());
out.extend_from_slice(b"tag ");
out.extend_from_slice(&self.name);
out.push(b'\n');
if let Some(tagger) = &self.tagger {
out.extend_from_slice(b"tagger ");
out.extend_from_slice(tagger);
out.push(b'\n');
}
out.push(b'\n');
out.extend_from_slice(&self.message);
out
}
pub fn tagger_signature(&self) -> Option<Signature> {
Signature::from_ident_line(self.tagger.as_deref()?)
}
}
impl<'a> TagRef<'a> {
pub fn parse(format: ObjectFormat, bytes: &'a [u8]) -> Result<Self> {
let split = bytes.windows(2).position(|window| window == b"\n\n");
let (headers, message) = match split {
Some(split) => (&bytes[..split], &bytes[split + 2..]),
None => (bytes, &bytes[bytes.len()..]),
};
let mut object = None;
let mut object_type = None;
let mut name = None;
let mut tagger = None;
for line in headers.split(|byte| *byte == b'\n') {
if let Some(value) = line.strip_prefix(b"object ") {
object = Some(ObjectId::from_hex(format, ascii_header_value(value)?)?);
} else if let Some(value) = line.strip_prefix(b"type ") {
object_type = Some(ascii_header_value(value)?.parse()?);
} else if let Some(value) = line.strip_prefix(b"tag ") {
name = Some(value);
} else if let Some(value) = line.strip_prefix(b"tagger ") {
tagger = Some(value);
}
}
Ok(Self {
object: object.ok_or_else(|| GitError::InvalidObject("tag missing object".into()))?,
object_type: object_type
.ok_or_else(|| GitError::InvalidObject("tag missing type".into()))?,
name: name.ok_or_else(|| GitError::InvalidObject("tag missing name".into()))?,
tagger,
message,
raw_body: Some(bytes),
})
}
pub fn to_owned(&self) -> Tag {
Tag {
object: self.object,
object_type: self.object_type,
name: self.name.to_vec(),
tagger: self.tagger.map(<[u8]>::to_vec),
message: self.message.to_vec(),
raw_body: self.raw_body.map(<[u8]>::to_vec),
}
}
pub fn tagger_signature(&self) -> Option<Signature> {
Signature::from_ident_line(self.tagger?)
}
}
impl<'a> From<TagRef<'a>> for Tag {
fn from(tag: TagRef<'a>) -> Self {
Self {
object: tag.object,
object_type: tag.object_type,
name: tag.name.to_vec(),
tagger: tag.tagger.map(<[u8]>::to_vec),
message: tag.message.to_vec(),
raw_body: tag.raw_body.map(<[u8]>::to_vec),
}
}
}
fn ascii_header_value(value: &[u8]) -> Result<&str> {
std::str::from_utf8(value).map_err(|err| GitError::InvalidObject(err.to_string()))
}
pub fn parse_framed_object(bytes: &[u8]) -> Result<EncodedObject> {
let nul = bytes
.iter()
.position(|byte| *byte == 0)
.ok_or_else(|| GitError::InvalidObject("missing object header terminator".into()))?;
let header = std::str::from_utf8(&bytes[..nul])
.map_err(|err| GitError::InvalidObject(err.to_string()))?;
let (kind, size) = header
.split_once(' ')
.ok_or_else(|| GitError::InvalidObject("missing object size".into()))?;
let size: usize = size
.parse()
.map_err(|_| GitError::InvalidObject("invalid object size".into()))?;
let body = &bytes[nul + 1..];
if body.len() != size {
return Err(GitError::InvalidObject(format!(
"object declared {size} bytes, found {}",
body.len()
)));
}
Ok(EncodedObject::new(kind.parse()?, body.to_vec()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tree_builder_sorts_canonically_and_dedups() {
let format = ObjectFormat::Sha1;
let blob = ObjectId::empty_blob(format);
let subtree = ObjectId::empty_tree(format);
assert_eq!(subtree.to_hex(), "4b825dc642cb6eb9a060e54bf8d69288fbee4904");
assert_eq!(blob.to_hex(), "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
let mut builder = TreeBuilder::new();
builder.upsert("foo", EntryKind::Tree, subtree);
builder.upsert("a.txt", EntryKind::Blob, blob.clone());
builder.upsert("foo.txt", EntryKind::Blob, blob.clone());
builder.upsert("a.txt", EntryKind::BlobExecutable, blob);
let tree = builder.build();
let names: Vec<&[u8]> = tree.entries.iter().map(|e| e.name.as_bytes()).collect();
assert_eq!(names, vec![&b"a.txt"[..], &b"foo.txt"[..], &b"foo"[..]]);
assert_eq!(tree.entries[0].mode, EntryKind::BlobExecutable.mode());
assert!(tree.entries[2].is_tree());
}
#[test]
fn entry_kind_round_trips_modes() {
for kind in [
EntryKind::Tree,
EntryKind::Blob,
EntryKind::BlobExecutable,
EntryKind::Symlink,
EntryKind::Commit,
] {
assert_eq!(EntryKind::from_mode(kind.mode()), Some(kind));
}
assert_eq!(EntryKind::from_mode(0o100600), None);
}
#[test]
fn framed_object_round_trips() {
let object = EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec());
assert_eq!(
parse_framed_object(&object.framed_bytes()).expect("test operation should succeed"),
object
);
}
#[test]
fn encoded_raw_commit_with_multiline_gpgsig_preserves_bytes_and_id() {
let format = ObjectFormat::Sha1;
let tree = ObjectId::empty_tree(format);
let body = format!(
concat!(
"tree {tree}\n",
"author Signer <signer@example.invalid> 1700000000 +0000\n",
"committer Signer <signer@example.invalid> 1700000000 +0000\n",
"gpgsig -----BEGIN PGP SIGNATURE-----\n",
" \n",
" iQEzBAABCgAdFiEErawcommitbytescontract\n",
" =abcd\n",
" -----END PGP SIGNATURE-----\n",
"\n",
"signed commit\n",
),
tree = tree,
)
.into_bytes();
assert_encoded_preserves_framed_bytes_and_id(ObjectType::Commit, body, format);
}
#[test]
fn encoded_raw_commit_with_mergetag_and_custom_headers_preserves_bytes_and_id() {
let format = ObjectFormat::Sha1;
let tree = ObjectId::empty_tree(format);
let parent = ObjectId::empty_blob(format);
let body = format!(
concat!(
"tree {tree}\n",
"parent {parent}\n",
"author Merger <merger@example.invalid> 1700000000 +0000\n",
"committer Merger <merger@example.invalid> 1700000001 +0000\n",
"x-review-id 42\n",
"mergetag object {parent}\n",
" type commit\n",
" tag imported-v1\n",
" tagger Tagger <tagger@example.invalid> 1699999999 +0000\n",
" \n",
" imported tag body\n",
" gpgsig -----BEGIN PGP SIGNATURE-----\n",
" nested-signature-line\n",
" -----END PGP SIGNATURE-----\n",
"x-sley-extra raw bytes stay here\n",
"\n",
"merge commit\n",
),
tree = tree,
parent = parent,
)
.into_bytes();
assert_encoded_preserves_framed_bytes_and_id(ObjectType::Commit, body, format);
}
#[test]
fn encoded_raw_annotated_tag_with_signature_and_custom_headers_preserves_bytes_and_id() {
let format = ObjectFormat::Sha1;
let object = ObjectId::empty_blob(format);
let body = format!(
concat!(
"object {object}\n",
"type blob\n",
"tag signed-v1\n",
"tagger Tagger <tagger@example.invalid> 1700000000 -0000\n",
"x-release-channel stable\n",
"gpgsig -----BEGIN PGP SIGNATURE-----\n",
" tag-signature-line-1\n",
" tag-signature-line-2\n",
" -----END PGP SIGNATURE-----\n",
"\n",
"release notes\n",
),
object = object,
)
.into_bytes();
assert_encoded_preserves_framed_bytes_and_id(ObjectType::Tag, body, format);
}
#[test]
fn tree_round_trips_entries() {
let blob = ObjectId::from_hex(
ObjectFormat::Sha1,
"ce013625030ba8dba906f756967f9e9ca394464a",
)
.expect("test operation should succeed");
let tree = Tree {
entries: vec![TreeEntry {
mode: 0o100644,
name: BString::from(b"hello.txt"),
oid: blob,
}],
};
assert_eq!(
Tree::parse(ObjectFormat::Sha1, &tree.write()).expect("test operation should succeed"),
tree
);
}
#[test]
fn tree_entries_iterates_without_name_allocations() {
let format = ObjectFormat::Sha1;
let blob = ObjectId::from_hex(format, "ce013625030ba8dba906f756967f9e9ca394464a")
.expect("test operation should succeed");
let subtree = ObjectId::empty_tree(format);
let mut bytes = Vec::new();
let first_name_start = b"100644 ".len();
write_tree_entry(&mut bytes, EntryKind::Blob.mode(), b"hello.txt", &blob);
let second_name_start = bytes.len() + b"40000 ".len();
write_tree_entry(&mut bytes, EntryKind::Tree.mode(), b"src", &subtree);
let mut entries = TreeEntries::new(format, &bytes);
let first = entries
.next()
.expect("first entry")
.expect("test operation should succeed");
assert_eq!(first.mode, EntryKind::Blob.mode());
assert_eq!(first.name, b"hello.txt");
assert_eq!(first.oid, blob);
assert_eq!(first.kind(), Some(EntryKind::Blob));
assert!(std::ptr::eq(
first.name.as_ptr(),
bytes[first_name_start..].as_ptr()
));
let second = entries
.next()
.expect("second entry")
.expect("test operation should succeed");
assert_eq!(second.mode, EntryKind::Tree.mode());
assert_eq!(second.name, b"src");
assert_eq!(second.oid, subtree);
assert!(second.is_tree());
assert!(std::ptr::eq(
second.name.as_ptr(),
bytes[second_name_start..].as_ptr()
));
assert!(entries.next().is_none());
let owned = Tree::parse(format, &bytes).expect("test operation should succeed");
assert_eq!(owned.entries, vec![first.to_owned(), second.to_owned()]);
}
#[test]
fn tree_entries_reports_invalid_mode_path_and_truncated_oid() {
let format = ObjectFormat::Sha1;
let oid = ObjectId::empty_blob(format);
let mut invalid_mode = b"10088 bad\0".to_vec();
invalid_mode.extend_from_slice(oid.as_bytes());
assert_invalid_tree_entry(
TreeEntries::new(format, &invalid_mode)
.next()
.expect("invalid mode result"),
"invalid tree mode",
);
let mut empty_path = b"100644 \0".to_vec();
empty_path.extend_from_slice(oid.as_bytes());
assert_invalid_tree_entry(
TreeEntries::new(format, &empty_path)
.next()
.expect("empty path result"),
"empty tree path",
);
let mut truncated_oid = b"100644 bad\0".to_vec();
truncated_oid.extend_from_slice(&oid.as_bytes()[..format.raw_len() - 1]);
assert_invalid_tree_entry(
TreeEntries::new(format, &truncated_oid)
.next()
.expect("truncated oid result"),
"truncated tree object id",
);
}
#[test]
fn tree_entry_ref_kind_helpers_match_entry_kinds() {
let oid = ObjectId::null(ObjectFormat::Sha1);
let tree = TreeEntryRef {
mode: EntryKind::Tree.mode(),
name: b"dir",
oid,
};
assert_eq!(tree.kind(), Some(EntryKind::Tree));
assert!(tree.is_tree());
assert!(!tree.is_symlink());
assert!(!tree.is_gitlink());
assert!(!tree.is_executable());
let symlink = TreeEntryRef {
mode: EntryKind::Symlink.mode(),
name: b"link",
oid,
};
assert_eq!(symlink.kind(), Some(EntryKind::Symlink));
assert!(symlink.is_symlink());
assert!(!symlink.is_tree());
assert!(!symlink.is_gitlink());
assert!(!symlink.is_executable());
let executable = TreeEntryRef {
mode: EntryKind::BlobExecutable.mode(),
name: b"run",
oid,
};
assert_eq!(executable.kind(), Some(EntryKind::BlobExecutable));
assert!(executable.is_executable());
assert!(!executable.is_tree());
assert!(!executable.is_symlink());
assert!(!executable.is_gitlink());
let gitlink = TreeEntryRef {
mode: EntryKind::Commit.mode(),
name: b"submodule",
oid,
};
assert_eq!(gitlink.kind(), Some(EntryKind::Commit));
assert!(gitlink.is_gitlink());
assert!(!gitlink.is_tree());
assert!(!gitlink.is_symlink());
assert!(!gitlink.is_executable());
}
#[test]
fn commit_round_trips_headers_and_message() {
let tree = ObjectId::from_hex(
ObjectFormat::Sha1,
"4b825dc642cb6eb9a060e54bf8d69288fbee4904",
)
.expect("test operation should succeed");
let commit = Commit {
tree,
parents: Vec::new(),
author: b"A U Thor <a@example.invalid> 0 +0000".to_vec(),
committer: b"C O Mitter <c@example.invalid> 0 +0000".to_vec(),
encoding: Some(b"ISO-8859-1".to_vec()),
message: b"subject\n\nbody\n".to_vec(),
};
assert_eq!(
Commit::parse(ObjectFormat::Sha1, &commit.write())
.expect("test operation should succeed"),
commit
);
}
#[test]
fn commit_ref_borrows_headers_and_message() {
let format = ObjectFormat::Sha1;
let tree_hex = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
let parent_hex = "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15";
let body = format!(
"tree {tree_hex}\n\
parent {parent_hex}\n\
author A U Thor <a@example.invalid> 0 +0000\n\
committer C O Mitter <c@example.invalid> 1 -0000\n\
encoding UTF-8\n\
\n\
subject\n\nbody\n"
)
.into_bytes();
let commit = CommitRef::parse(format, &body).expect("test operation should succeed");
assert_eq!(
commit.tree,
ObjectId::from_hex(format, tree_hex).expect("test operation should succeed")
);
assert_eq!(
commit.parents,
vec![ObjectId::from_hex(format, parent_hex).expect("test operation should succeed")]
);
assert_borrows_from(
&body,
commit.author,
b"A U Thor <a@example.invalid> 0 +0000",
);
assert_borrows_from(
&body,
commit.committer,
b"C O Mitter <c@example.invalid> 1 -0000",
);
assert_borrows_from(
&body,
commit.encoding.expect("test operation should succeed"),
b"UTF-8",
);
assert_borrows_from(&body, commit.message, b"subject\n\nbody\n");
assert_eq!(
Commit::parse_ref(format, &body).expect("test operation should succeed"),
commit
);
assert_eq!(
commit.to_owned(),
Commit::parse(format, &body).expect("test operation should succeed")
);
}
#[test]
fn commit_ref_accepts_non_utf8_headers_and_message() {
let format = ObjectFormat::Sha1;
let tree = ObjectId::empty_tree(format);
let mut body = Vec::new();
body.extend_from_slice(format!("tree {tree}\n").as_bytes());
body.extend_from_slice(b"author J\xF6rg <j@example.invalid> 0 +0000\n");
body.extend_from_slice(b"committer M\xFCller <m@example.invalid> 1 +0000\n");
body.extend_from_slice(b"encoding ISO-8859-1\n\n");
body.extend_from_slice(b"caf\xE9\n");
let commit = CommitRef::parse(format, &body).expect("non-utf8 commit parses");
assert_eq!(commit.tree, tree);
assert_borrows_from(&body, commit.author, b"J\xF6rg <j@example.invalid> 0 +0000");
assert_borrows_from(
&body,
commit.committer,
b"M\xFCller <m@example.invalid> 1 +0000",
);
assert_borrows_from(&body, commit.encoding.expect("encoding"), b"ISO-8859-1");
assert_borrows_from(&body, commit.message, b"caf\xE9\n");
assert_eq!(commit.to_owned().write(), body);
}
#[test]
fn commit_ref_rejects_missing_or_malformed_required_headers() {
let format = ObjectFormat::Sha1;
let valid_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
let valid_idents =
b"author A U Thor <a@example.invalid> 0 +0000\ncommitter C O Mitter <c@example.invalid> 0 +0000\n\nmessage\n";
let mut missing_tree = Vec::new();
missing_tree.extend_from_slice(valid_idents);
assert_invalid_object(
CommitRef::parse(format, &missing_tree),
"commit missing tree",
);
let malformed_tree = b"tree not-an-object-id\nauthor A U Thor <a@example.invalid> 0 +0000\ncommitter C O Mitter <c@example.invalid> 0 +0000\n\nmessage\n";
assert!(matches!(
CommitRef::parse(format, malformed_tree),
Err(GitError::InvalidObjectId(_))
));
let missing_committer =
format!("tree {valid_tree}\nauthor A U Thor <a@example.invalid> 0 +0000\n\nmessage\n")
.into_bytes();
assert_invalid_object(
CommitRef::parse(format, &missing_committer),
"commit missing committer",
);
}
#[test]
fn tag_round_trips_headers_and_message() {
let object = ObjectId::from_hex(
ObjectFormat::Sha1,
"e7556fb3ba7b8f5b1f4772180772a4d6a7323e15",
)
.expect("test operation should succeed");
let tag = Tag {
object,
object_type: ObjectType::Commit,
name: b"v1.0".to_vec(),
tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
message: b"release\n".to_vec(),
raw_body: None,
};
assert_eq!(
Tag::parse(ObjectFormat::Sha1, &tag.write()).expect("test operation should succeed"),
tag
);
}
#[test]
fn tag_ref_accepts_non_utf8_tagger_and_message() {
let format = ObjectFormat::Sha1;
let object = ObjectId::empty_blob(format);
let mut body = Vec::new();
body.extend_from_slice(format!("object {object}\n").as_bytes());
body.extend_from_slice(b"type blob\n");
body.extend_from_slice(b"tag v1.0\n");
body.extend_from_slice(b"tagger J\xF6rg <j@example.invalid> 0 +0000\n\n");
body.extend_from_slice(b"caf\xE9\n");
let tag = TagRef::parse(format, &body).expect("non-utf8 tag parses");
assert_eq!(tag.object, object);
assert_eq!(tag.object_type, ObjectType::Blob);
assert_borrows_from(&body, tag.name, b"v1.0");
assert_borrows_from(
&body,
tag.tagger.expect("tagger"),
b"J\xF6rg <j@example.invalid> 0 +0000",
);
assert_borrows_from(&body, tag.message, b"caf\xE9\n");
assert_eq!(tag.to_owned().write(), body);
}
#[test]
fn typed_commit_canonicalizes_but_tag_write_preserves_raw_body() {
let format = ObjectFormat::Sha1;
let tree = ObjectId::empty_tree(format);
let raw_commit = format!(
concat!(
"tree {tree}\n",
"author A U Thor <a@example.invalid> 0 +0000\n",
"x-hidden keep only in raw encoded object\n",
"committer C O Mitter <c@example.invalid> 0 +0000\n",
"gpgsig -----BEGIN PGP SIGNATURE-----\n",
" typed-parser-accepts-this\n",
" -----END PGP SIGNATURE-----\n",
"\n",
"subject\n",
),
tree = tree,
)
.into_bytes();
let commit = Commit::parse(format, &raw_commit).expect("test operation should succeed");
assert_eq!(commit.tree, tree);
assert_eq!(commit.author, b"A U Thor <a@example.invalid> 0 +0000");
assert_eq!(commit.committer, b"C O Mitter <c@example.invalid> 0 +0000");
assert_eq!(commit.message, b"subject\n");
let written_commit = commit.write();
assert_ne!(written_commit, raw_commit);
assert_bytes_not_contains(&written_commit, b"x-hidden");
assert_bytes_not_contains(&written_commit, b"gpgsig");
let object = ObjectId::empty_blob(format);
let raw_tag = format!(
concat!(
"object {object}\n",
"type blob\n",
"tag v1.0\n",
"x-hidden keep only in raw encoded object\n",
"tagger Example User <example@example.invalid> 0 +0000\n",
"gpgsig -----BEGIN PGP SIGNATURE-----\n",
" typed-parser-accepts-this-too\n",
" -----END PGP SIGNATURE-----\n",
"\n",
"release\n",
),
object = object,
)
.into_bytes();
let tag = Tag::parse(format, &raw_tag).expect("test operation should succeed");
assert_eq!(tag.object, object);
assert_eq!(tag.object_type, ObjectType::Blob);
assert_eq!(tag.name, b"v1.0");
assert_eq!(
tag.tagger.as_deref(),
Some(&b"Example User <example@example.invalid> 0 +0000"[..])
);
assert_eq!(tag.message, b"release\n");
let written_tag = tag.write();
assert_eq!(written_tag, raw_tag);
let original_oid = EncodedObject::new(ObjectType::Tag, raw_tag).object_id(format);
let written_oid = EncodedObject::new(ObjectType::Tag, written_tag).object_id(format);
assert_eq!(
original_oid.expect("original tag oid"),
written_oid.expect("written tag oid")
);
}
#[test]
fn tag_parse_write_preserves_uppercase_object_and_header_only_body() {
let format = ObjectFormat::Sha1;
let object = ObjectId::empty_blob(format);
let mut raw_tag = Vec::new();
raw_tag.extend_from_slice(
format!("object {}\n", object.to_string().to_uppercase()).as_bytes(),
);
raw_tag.extend_from_slice(b"type blob\n");
raw_tag.extend_from_slice(b"tag v1.0\n");
raw_tag.extend_from_slice(b"tagger Example <example@example.invalid> 0 +0000\n");
let tag = Tag::parse(format, &raw_tag).expect("header-only tag parses");
assert_eq!(tag.object, object);
assert_eq!(tag.message, b"");
assert_eq!(tag.write(), raw_tag);
}
#[test]
fn tag_ref_borrows_name_tagger_and_message() {
let format = ObjectFormat::Sha1;
let object_hex = "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15";
let body = format!(
"object {object_hex}\n\
type commit\n\
tag v1.0-borrowed\n\
tagger Example User <example@example.invalid> 0 +0000\n\
\n\
release notes\n"
)
.into_bytes();
let tag = TagRef::parse(format, &body).expect("test operation should succeed");
assert_eq!(
tag.object,
ObjectId::from_hex(format, object_hex).expect("test operation should succeed")
);
assert_eq!(tag.object_type, ObjectType::Commit);
assert_borrows_from(&body, tag.name, b"v1.0-borrowed");
assert_borrows_from(
&body,
tag.tagger.expect("test operation should succeed"),
b"Example User <example@example.invalid> 0 +0000",
);
assert_borrows_from(&body, tag.message, b"release notes\n");
assert_eq!(
Tag::parse_ref(format, &body).expect("test operation should succeed"),
tag
);
assert_eq!(
tag.to_owned(),
Tag::parse(format, &body).expect("test operation should succeed")
);
}
#[test]
fn tag_ref_rejects_missing_or_malformed_required_headers() {
let format = ObjectFormat::Sha1;
let object_hex = "e7556fb3ba7b8f5b1f4772180772a4d6a7323e15";
let missing_name = format!("object {object_hex}\ntype commit\n\nmessage\n").into_bytes();
assert_invalid_object(TagRef::parse(format, &missing_name), "tag missing name");
let malformed_object = b"object not-an-object-id\ntype commit\ntag v1.0\n\nmessage\n";
assert!(matches!(
TagRef::parse(format, malformed_object),
Err(GitError::InvalidObjectId(_))
));
let malformed_type =
format!("object {object_hex}\ntype mystery\ntag v1.0\n\nmessage\n").into_bytes();
assert_invalid_object(
TagRef::parse(format, &malformed_type),
"unknown object type mystery",
);
}
#[test]
fn commit_signature_accessors_parse_raw_idents_without_changing_storage() {
let tree = ObjectId::from_hex(
ObjectFormat::Sha1,
"4b825dc642cb6eb9a060e54bf8d69288fbee4904",
)
.expect("test operation should succeed");
let author_raw = b"A U Thor <a@example.invalid> 1700000000 +0530".to_vec();
let committer_raw = b"C O Mitter <c@example.invalid> 1700000001 -0000".to_vec();
let commit = Commit {
tree,
parents: Vec::new(),
author: author_raw.clone(),
committer: committer_raw.clone(),
encoding: None,
message: b"subject\n".to_vec(),
};
let author = commit.author_signature().expect("author parses");
assert_eq!(author.name.as_bytes(), b"A U Thor");
assert_eq!(author.email.as_bytes(), b"a@example.invalid");
assert_eq!(author.time.seconds, 1_700_000_000);
assert_eq!(author.time.timezone_offset_minutes, 330);
assert!(!author.time.negative_utc);
assert_eq!(author.to_ident_bytes(), author_raw);
let committer = commit.committer_signature().expect("committer parses");
assert_eq!(committer.time.seconds, 1_700_000_001);
assert!(committer.time.negative_utc);
assert_eq!(committer.to_ident_bytes(), committer_raw);
assert_eq!(commit.author, author_raw);
assert_eq!(commit.committer, committer_raw);
let written = commit.write();
assert_eq!(
Commit::parse(ObjectFormat::Sha1, &written).expect("test operation should succeed"),
commit
);
}
#[test]
fn commit_signature_accessor_is_none_for_malformed_ident() {
let tree = ObjectId::from_hex(
ObjectFormat::Sha1,
"4b825dc642cb6eb9a060e54bf8d69288fbee4904",
)
.expect("test operation should succeed");
let commit = Commit {
tree,
parents: Vec::new(),
author: b"garbage without an email or time".to_vec(),
committer: b"C O Mitter <c@example.invalid> 0 +0000".to_vec(),
encoding: None,
message: b"x\n".to_vec(),
};
assert!(commit.author_signature().is_none());
assert!(commit.committer_signature().is_some());
}
#[test]
fn tag_signature_accessor_parses_tagger_and_handles_absence() {
let object = ObjectId::from_hex(
ObjectFormat::Sha1,
"e7556fb3ba7b8f5b1f4772180772a4d6a7323e15",
)
.expect("test operation should succeed");
let tagger_raw = b"Example User <example@example.invalid> 1700000000 -0000".to_vec();
let tag = Tag {
object: object.clone(),
object_type: ObjectType::Commit,
name: b"v1.0".to_vec(),
tagger: Some(tagger_raw.clone()),
message: b"release\n".to_vec(),
raw_body: None,
};
let tagger = tag.tagger_signature().expect("tagger parses");
assert_eq!(tagger.name.as_bytes(), b"Example User");
assert!(tagger.time.negative_utc);
assert_eq!(tagger.to_ident_bytes(), tagger_raw);
assert_eq!(tag.tagger.as_deref(), Some(tagger_raw.as_slice()));
let lightweight = Tag {
object,
object_type: ObjectType::Commit,
name: b"v1.0".to_vec(),
tagger: None,
message: b"x\n".to_vec(),
raw_body: None,
};
assert!(lightweight.tagger_signature().is_none());
}
fn write_tree_entry(body: &mut Vec<u8>, mode: u32, name: &[u8], oid: &ObjectId) {
body.extend_from_slice(format!("{:o}", mode).as_bytes());
body.push(b' ');
body.extend_from_slice(name);
body.push(0);
body.extend_from_slice(oid.as_bytes());
}
fn assert_invalid_tree_entry(result: Result<TreeEntryRef<'_>>, expected: &str) {
match result {
Err(GitError::InvalidFormat(message)) => assert_eq!(message, expected),
other => panic!("expected invalid format {expected:?}, got {other:?}"),
}
}
fn assert_invalid_object<T: std::fmt::Debug>(result: Result<T>, expected: &str) {
match result {
Err(GitError::InvalidObject(message)) => assert_eq!(message, expected),
other => panic!("expected invalid object {expected:?}, got {other:?}"),
}
}
fn assert_encoded_preserves_framed_bytes_and_id(
object_type: ObjectType,
body: Vec<u8>,
format: ObjectFormat,
) {
let object = EncodedObject::new(object_type, body.clone());
let expected_id = object
.object_id(format)
.expect("test operation should succeed");
let framed = object.framed_bytes();
let parsed = parse_framed_object(&framed).expect("test operation should succeed");
assert_eq!(parsed.object_type, object_type);
assert_eq!(parsed.body, body);
assert_eq!(
parsed
.object_id(format)
.expect("test operation should succeed"),
expected_id
);
assert_eq!(parsed.framed_bytes(), framed);
}
fn assert_bytes_not_contains(haystack: &[u8], needle: &[u8]) {
assert!(
!haystack
.windows(needle.len())
.any(|window| window == needle),
"expected bytes not to contain {:?}",
String::from_utf8_lossy(needle)
);
}
fn assert_borrows_from(body: &[u8], slice: &[u8], expected: &[u8]) {
assert_eq!(slice, expected);
let offset = body
.windows(expected.len())
.position(|window| window == expected)
.expect("expected slice appears in body");
assert!(std::ptr::eq(slice.as_ptr(), body[offset..].as_ptr()));
}
}