use sha1::Digest as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GitObjectKind {
Blob,
Tree,
Commit,
Tag,
}
impl GitObjectKind {
pub fn as_str(self) -> &'static str {
match self {
GitObjectKind::Blob => "blob",
GitObjectKind::Tree => "tree",
GitObjectKind::Commit => "commit",
GitObjectKind::Tag => "tag",
}
}
pub fn from_bytes(b: &[u8]) -> Option<Self> {
match b {
b"blob" => Some(GitObjectKind::Blob),
b"tree" => Some(GitObjectKind::Tree),
b"commit" => Some(GitObjectKind::Commit),
b"tag" => Some(GitObjectKind::Tag),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct GitObject<'a> {
pub kind: GitObjectKind,
pub payload: &'a [u8],
}
pub fn parse_canonical(data: &[u8]) -> Option<GitObject<'_>> {
let scan = data.len().min(64);
let nul = data[..scan].iter().position(|&b| b == 0)?;
let header = &data[..nul];
let sp = header.iter().position(|&b| b == b' ')?;
let kind = GitObjectKind::from_bytes(&header[..sp])?;
let size_txt = std::str::from_utf8(&header[sp + 1..]).ok()?;
if size_txt.is_empty() || !size_txt.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let size: usize = size_txt.parse().ok()?;
let payload = &data[nul + 1..];
if payload.len() != size {
return None;
}
Some(GitObject { kind, payload })
}
pub fn canonical(kind: GitObjectKind, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(payload.len() + 32);
out.extend_from_slice(kind.as_str().as_bytes());
out.push(b' ');
out.extend_from_slice(payload.len().to_string().as_bytes());
out.push(0);
out.extend_from_slice(payload);
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitHashKind {
Sha1,
Sha256,
}
impl GitHashKind {
pub fn oid_len(self) -> usize {
match self {
GitHashKind::Sha1 => 20,
GitHashKind::Sha256 => 32,
}
}
pub fn hex_len(self) -> usize {
self.oid_len() * 2
}
pub fn from_hex_len(len: usize) -> Option<Self> {
match len {
40 => Some(GitHashKind::Sha1),
64 => Some(GitHashKind::Sha256),
_ => None,
}
}
pub fn code(self) -> u8 {
match self {
GitHashKind::Sha1 => 1,
GitHashKind::Sha256 => 2,
}
}
pub fn from_code(c: u8) -> Option<Self> {
match c {
1 => Some(GitHashKind::Sha1),
2 => Some(GitHashKind::Sha256),
_ => None,
}
}
pub fn oid_of(self, canonical_bytes: &[u8]) -> Vec<u8> {
match self {
GitHashKind::Sha1 => sha1::Sha1::digest(canonical_bytes).to_vec(),
GitHashKind::Sha256 => sha2::Sha256::digest(canonical_bytes).to_vec(),
}
}
pub fn oid_hex_of(self, canonical_bytes: &[u8]) -> String {
hex::encode(self.oid_of(canonical_bytes))
}
}
pub fn is_oid_path(path: &str) -> bool {
matches!(path.len(), 40 | 64) && path.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PackFileKind {
Data,
Index,
}
impl PackFileKind {
pub fn as_str(self) -> &'static str {
match self {
PackFileKind::Data => PACKFILE_TYPE,
PackFileKind::Index => PACK_INDEX_TYPE,
}
}
pub fn magic(self) -> &'static [u8] {
match self {
PackFileKind::Data => b"PACK",
PackFileKind::Index => b"\xfftOc",
}
}
}
pub const PACKFILE_TYPE: &str = "packfile";
pub const PACK_INDEX_TYPE: &str = "pack-index";
pub fn pack_path_kind(path: &str) -> Option<PackFileKind> {
let name = path.rsplit('/').next().unwrap_or(path);
let stem = name.strip_prefix("pack-")?;
let (id, kind) = if let Some(id) = stem.strip_suffix(".pack") {
(id, PackFileKind::Data)
} else {
(stem.strip_suffix(".idx")?, PackFileKind::Index)
};
is_oid_path(id).then_some(kind)
}
#[derive(Debug, Clone)]
pub struct TreeEntry<'a> {
pub mode: &'a [u8],
pub name: &'a [u8],
pub oid: &'a [u8],
}
pub fn tree_entries(payload: &[u8], oid_len: usize) -> Vec<TreeEntry<'_>> {
let mut out = Vec::new();
let mut i = 0usize;
while i < payload.len() {
let Some(sp_rel) = payload[i..].iter().position(|&b| b == b' ') else { break };
let sp = i + sp_rel;
let Some(nul_rel) = payload[sp + 1..].iter().position(|&b| b == 0) else { break };
let nul = sp + 1 + nul_rel;
let end = nul + 1 + oid_len;
if end > payload.len() {
break;
}
out.push(TreeEntry {
mode: &payload[i..sp],
name: &payload[sp + 1..nul],
oid: &payload[nul + 1..end],
});
i = end;
}
out
}
#[derive(Debug, Clone, Default)]
pub struct CommitHeader {
pub tree: Option<String>,
pub parents: Vec<String>,
pub committer_time: Option<i64>,
}
pub fn parse_commit(payload: &[u8]) -> CommitHeader {
let mut h = CommitHeader::default();
let mut rest = payload;
loop {
let line_end = match rest.iter().position(|&b| b == b'\n') {
Some(p) => p,
None => rest.len(),
};
let line = &rest[..line_end];
if line.is_empty() {
break;
}
if let Some(v) = line.strip_prefix(b"tree ") {
if let Ok(s) = std::str::from_utf8(v) {
if is_oid_path(s) {
h.tree = Some(s.to_string());
}
}
} else if let Some(v) = line.strip_prefix(b"parent ") {
if let Ok(s) = std::str::from_utf8(v) {
if is_oid_path(s) {
h.parents.push(s.to_string());
}
}
} else if let Some(v) = line.strip_prefix(b"committer ") {
h.committer_time = committer_timestamp(v);
}
if line_end >= rest.len() {
break;
}
rest = &rest[line_end + 1..];
}
h
}
fn committer_timestamp(v: &[u8]) -> Option<i64> {
let s = std::str::from_utf8(v).ok()?;
let gt = s.rfind('>')?;
let mut it = s[gt + 1..].split_ascii_whitespace();
it.next()?.parse::<i64>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_roundtrips_and_hashes_like_git() {
let c = canonical(GitObjectKind::Blob, b"");
assert_eq!(c, b"blob 0\0");
assert_eq!(
GitHashKind::Sha1.oid_hex_of(&c),
"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
);
assert_eq!(
GitHashKind::Sha256.oid_hex_of(&c),
"473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813"
);
}
#[test]
fn hash_object_matches_git_for_nonempty_blob() {
let c = canonical(GitObjectKind::Blob, b"hello");
assert_eq!(
GitHashKind::Sha1.oid_hex_of(&c),
"b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0"
);
}
#[test]
fn parse_rejects_size_mismatch_instead_of_lying() {
assert!(parse_canonical(b"blob 5\0abcd").is_none());
assert!(parse_canonical(b"blob 4\0abcd").is_some());
}
#[test]
fn parse_never_panics_on_garbage() {
for bad in [
&b""[..],
&b"\0"[..],
&b"blob"[..],
&b"blob \0"[..],
&b"nope 3\0abc"[..],
&b"blob -1\0abc"[..],
&b"blob 99999999999999999999999999\0abc"[..],
&[0xffu8; 300][..],
] {
assert!(parse_canonical(bad).is_none(), "should not parse: {bad:?}");
}
}
#[test]
fn oid_path_shape_is_strict() {
assert!(is_oid_path(&"a".repeat(40)));
assert!(is_oid_path(&"0".repeat(64)));
assert!(!is_oid_path(&"A".repeat(40)), "uppercase hex is not a git oid path");
assert!(!is_oid_path(&"a".repeat(41)));
assert!(!is_oid_path("objects/aa/bb"));
assert!(!is_oid_path(&"g".repeat(40)));
}
#[test]
fn tree_entries_parse_and_tolerate_truncation() {
let mut payload = Vec::new();
payload.extend_from_slice(b"100644 a.txt\0");
payload.extend_from_slice(&[0x11u8; 20]);
payload.extend_from_slice(b"40000 sub\0");
payload.extend_from_slice(&[0x22u8; 20]);
let e = tree_entries(&payload, 20);
assert_eq!(e.len(), 2);
assert_eq!(e[0].name, b"a.txt");
assert_eq!(e[1].oid, &[0x22u8; 20]);
let t = tree_entries(&payload[..payload.len() - 5], 20);
assert_eq!(t.len(), 1);
}
#[test]
fn commit_header_parses_tree_parents_and_time() {
let payload = concat!(
"tree 1111111111111111111111111111111111111111\n",
"parent 2222222222222222222222222222222222222222\n",
"parent 3333333333333333333333333333333333333333\n",
"author A <a@x> 1700000000 +0100\n",
"committer C <c@x> 1700000123 +0200\n",
"\n",
"message body\n",
);
let h = parse_commit(payload.as_bytes());
assert_eq!(h.tree.as_deref(), Some("1111111111111111111111111111111111111111"));
assert_eq!(h.parents.len(), 2);
assert_eq!(h.committer_time, Some(1700000123));
}
}