1use sha1::Digest as _;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum GitObjectKind {
21 Blob,
22 Tree,
23 Commit,
24 Tag,
25}
26
27impl GitObjectKind {
28 pub fn as_str(self) -> &'static str {
29 match self {
30 GitObjectKind::Blob => "blob",
31 GitObjectKind::Tree => "tree",
32 GitObjectKind::Commit => "commit",
33 GitObjectKind::Tag => "tag",
34 }
35 }
36
37 pub fn from_bytes(b: &[u8]) -> Option<Self> {
38 match b {
39 b"blob" => Some(GitObjectKind::Blob),
40 b"tree" => Some(GitObjectKind::Tree),
41 b"commit" => Some(GitObjectKind::Commit),
42 b"tag" => Some(GitObjectKind::Tag),
43 _ => None,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy)]
50pub struct GitObject<'a> {
51 pub kind: GitObjectKind,
52 pub payload: &'a [u8],
53}
54
55pub fn parse_canonical(data: &[u8]) -> Option<GitObject<'_>> {
62 let scan = data.len().min(64);
64 let nul = data[..scan].iter().position(|&b| b == 0)?;
65 let header = &data[..nul];
66 let sp = header.iter().position(|&b| b == b' ')?;
67 let kind = GitObjectKind::from_bytes(&header[..sp])?;
68 let size_txt = std::str::from_utf8(&header[sp + 1..]).ok()?;
69 if size_txt.is_empty() || !size_txt.bytes().all(|b| b.is_ascii_digit()) {
70 return None;
71 }
72 let size: usize = size_txt.parse().ok()?;
73 let payload = &data[nul + 1..];
74 if payload.len() != size {
75 return None;
76 }
77 Some(GitObject { kind, payload })
78}
79
80pub fn canonical(kind: GitObjectKind, payload: &[u8]) -> Vec<u8> {
83 let mut out = Vec::with_capacity(payload.len() + 32);
84 out.extend_from_slice(kind.as_str().as_bytes());
85 out.push(b' ');
86 out.extend_from_slice(payload.len().to_string().as_bytes());
87 out.push(0);
88 out.extend_from_slice(payload);
89 out
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum GitHashKind {
96 Sha1,
97 Sha256,
98}
99
100impl GitHashKind {
101 pub fn oid_len(self) -> usize {
103 match self {
104 GitHashKind::Sha1 => 20,
105 GitHashKind::Sha256 => 32,
106 }
107 }
108
109 pub fn hex_len(self) -> usize {
111 self.oid_len() * 2
112 }
113
114 pub fn from_hex_len(len: usize) -> Option<Self> {
116 match len {
117 40 => Some(GitHashKind::Sha1),
118 64 => Some(GitHashKind::Sha256),
119 _ => None,
120 }
121 }
122
123 pub fn code(self) -> u8 {
124 match self {
125 GitHashKind::Sha1 => 1,
126 GitHashKind::Sha256 => 2,
127 }
128 }
129
130 pub fn from_code(c: u8) -> Option<Self> {
131 match c {
132 1 => Some(GitHashKind::Sha1),
133 2 => Some(GitHashKind::Sha256),
134 _ => None,
135 }
136 }
137
138 pub fn oid_of(self, canonical_bytes: &[u8]) -> Vec<u8> {
140 match self {
141 GitHashKind::Sha1 => sha1::Sha1::digest(canonical_bytes).to_vec(),
142 GitHashKind::Sha256 => sha2::Sha256::digest(canonical_bytes).to_vec(),
143 }
144 }
145
146 pub fn oid_of_object(self, kind: GitObjectKind, payload: &[u8]) -> Vec<u8> {
153 let mut header = [0u8; 40];
154 let mut n = 0;
155 for b in kind.as_str().as_bytes() {
156 header[n] = *b;
157 n += 1;
158 }
159 header[n] = b' ';
160 n += 1;
161 let mut len = payload.len();
162 let mut digits = [0u8; 20];
163 let mut d = 0;
164 if len == 0 {
165 digits[0] = b'0';
166 d = 1;
167 }
168 while len > 0 {
169 digits[d] = b'0' + (len % 10) as u8;
170 len /= 10;
171 d += 1;
172 }
173 while d > 0 {
174 d -= 1;
175 header[n] = digits[d];
176 n += 1;
177 }
178 header[n] = 0;
179 n += 1;
180 match self {
181 GitHashKind::Sha1 => {
182 let mut h = sha1::Sha1::new();
183 h.update(&header[..n]);
184 h.update(payload);
185 h.finalize().to_vec()
186 }
187 GitHashKind::Sha256 => {
188 let mut h = sha2::Sha256::new();
189 h.update(&header[..n]);
190 h.update(payload);
191 h.finalize().to_vec()
192 }
193 }
194 }
195
196 pub fn oid_hex_of(self, canonical_bytes: &[u8]) -> String {
198 hex::encode(self.oid_of(canonical_bytes))
199 }
200}
201
202pub fn is_oid_path(path: &str) -> bool {
207 matches!(path.len(), 40 | 64) && path.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
217pub enum PackFileKind {
218 Data,
220 Index,
222}
223
224impl PackFileKind {
225 pub fn as_str(self) -> &'static str {
226 match self {
227 PackFileKind::Data => PACKFILE_TYPE,
228 PackFileKind::Index => PACK_INDEX_TYPE,
229 }
230 }
231
232 pub fn magic(self) -> &'static [u8] {
235 match self {
236 PackFileKind::Data => b"PACK",
237 PackFileKind::Index => b"\xfftOc",
239 }
240 }
241}
242
243pub const PACKFILE_TYPE: &str = "packfile";
245
246pub const PACK_INDEX_TYPE: &str = "pack-index";
248
249pub fn pack_path_kind(path: &str) -> Option<PackFileKind> {
261 let name = path.rsplit('/').next().unwrap_or(path);
262 let stem = name.strip_prefix("pack-")?;
263 let (id, kind) = if let Some(id) = stem.strip_suffix(".pack") {
264 (id, PackFileKind::Data)
265 } else {
266 (stem.strip_suffix(".idx")?, PackFileKind::Index)
267 };
268 is_oid_path(id).then_some(kind)
269}
270
271#[derive(Debug, Clone)]
273pub struct TreeEntry<'a> {
274 pub mode: &'a [u8],
275 pub name: &'a [u8],
276 pub oid: &'a [u8],
277}
278
279pub fn tree_entries(payload: &[u8], oid_len: usize) -> Vec<TreeEntry<'_>> {
284 let mut out = Vec::new();
285 let mut i = 0usize;
286 while i < payload.len() {
287 let Some(sp_rel) = payload[i..].iter().position(|&b| b == b' ') else { break };
288 let sp = i + sp_rel;
289 let Some(nul_rel) = payload[sp + 1..].iter().position(|&b| b == 0) else { break };
290 let nul = sp + 1 + nul_rel;
291 let end = nul + 1 + oid_len;
292 if end > payload.len() {
293 break;
294 }
295 out.push(TreeEntry {
296 mode: &payload[i..sp],
297 name: &payload[sp + 1..nul],
298 oid: &payload[nul + 1..end],
299 });
300 i = end;
301 }
302 out
303}
304
305#[derive(Debug, Clone, Default)]
307pub struct CommitHeader {
308 pub tree: Option<String>,
309 pub parents: Vec<String>,
310 pub committer_time: Option<i64>,
312}
313
314pub fn parse_commit(payload: &[u8]) -> CommitHeader {
317 let mut h = CommitHeader::default();
318 let mut rest = payload;
319 loop {
320 let line_end = match rest.iter().position(|&b| b == b'\n') {
321 Some(p) => p,
322 None => rest.len(),
323 };
324 let line = &rest[..line_end];
325 if line.is_empty() {
326 break;
327 }
328 if let Some(v) = line.strip_prefix(b"tree ") {
329 if let Ok(s) = std::str::from_utf8(v) {
330 if is_oid_path(s) {
331 h.tree = Some(s.to_string());
332 }
333 }
334 } else if let Some(v) = line.strip_prefix(b"parent ") {
335 if let Ok(s) = std::str::from_utf8(v) {
336 if is_oid_path(s) {
337 h.parents.push(s.to_string());
338 }
339 }
340 } else if let Some(v) = line.strip_prefix(b"committer ") {
341 h.committer_time = committer_timestamp(v);
342 }
343 if line_end >= rest.len() {
344 break;
345 }
346 rest = &rest[line_end + 1..];
347 }
348 h
349}
350
351fn committer_timestamp(v: &[u8]) -> Option<i64> {
353 let s = std::str::from_utf8(v).ok()?;
354 let gt = s.rfind('>')?;
355 let mut it = s[gt + 1..].split_ascii_whitespace();
356 it.next()?.parse::<i64>().ok()
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn canonical_roundtrips_and_hashes_like_git() {
365 let c = canonical(GitObjectKind::Blob, b"");
368 assert_eq!(c, b"blob 0\0");
369 assert_eq!(
370 GitHashKind::Sha1.oid_hex_of(&c),
371 "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
372 );
373 assert_eq!(
375 GitHashKind::Sha256.oid_hex_of(&c),
376 "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813"
377 );
378 }
379
380 #[test]
381 fn hash_object_matches_git_for_nonempty_blob() {
382 let c = canonical(GitObjectKind::Blob, b"hello");
384 assert_eq!(
385 GitHashKind::Sha1.oid_hex_of(&c),
386 "b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0"
387 );
388 }
389
390 #[test]
391 fn parse_rejects_size_mismatch_instead_of_lying() {
392 assert!(parse_canonical(b"blob 5\0abcd").is_none());
394 assert!(parse_canonical(b"blob 4\0abcd").is_some());
395 }
396
397 #[test]
398 fn parse_never_panics_on_garbage() {
399 for bad in [
400 &b""[..],
401 &b"\0"[..],
402 &b"blob"[..],
403 &b"blob \0"[..],
404 &b"nope 3\0abc"[..],
405 &b"blob -1\0abc"[..],
406 &b"blob 99999999999999999999999999\0abc"[..],
407 &[0xffu8; 300][..],
408 ] {
409 assert!(parse_canonical(bad).is_none(), "should not parse: {bad:?}");
410 }
411 }
412
413 #[test]
414 fn oid_path_shape_is_strict() {
415 assert!(is_oid_path(&"a".repeat(40)));
416 assert!(is_oid_path(&"0".repeat(64)));
417 assert!(!is_oid_path(&"A".repeat(40)), "uppercase hex is not a git oid path");
418 assert!(!is_oid_path(&"a".repeat(41)));
419 assert!(!is_oid_path("objects/aa/bb"));
420 assert!(!is_oid_path(&"g".repeat(40)));
421 }
422
423 #[test]
424 fn tree_entries_parse_and_tolerate_truncation() {
425 let mut payload = Vec::new();
426 payload.extend_from_slice(b"100644 a.txt\0");
427 payload.extend_from_slice(&[0x11u8; 20]);
428 payload.extend_from_slice(b"40000 sub\0");
429 payload.extend_from_slice(&[0x22u8; 20]);
430 let e = tree_entries(&payload, 20);
431 assert_eq!(e.len(), 2);
432 assert_eq!(e[0].name, b"a.txt");
433 assert_eq!(e[1].oid, &[0x22u8; 20]);
434
435 let t = tree_entries(&payload[..payload.len() - 5], 20);
437 assert_eq!(t.len(), 1);
438 }
439
440 #[test]
441 fn commit_header_parses_tree_parents_and_time() {
442 let payload = concat!(
443 "tree 1111111111111111111111111111111111111111\n",
444 "parent 2222222222222222222222222222222222222222\n",
445 "parent 3333333333333333333333333333333333333333\n",
446 "author A <a@x> 1700000000 +0100\n",
447 "committer C <c@x> 1700000123 +0200\n",
448 "\n",
449 "message body\n",
450 );
451 let h = parse_commit(payload.as_bytes());
452 assert_eq!(h.tree.as_deref(), Some("1111111111111111111111111111111111111111"));
453 assert_eq!(h.parents.len(), 2);
454 assert_eq!(h.committer_time, Some(1700000123));
455 }
456
457 #[test]
458 fn oid_of_object_is_oid_of_canonical() {
459 for kind in [GitObjectKind::Blob, GitObjectKind::Tree, GitObjectKind::Commit, GitObjectKind::Tag] {
460 for payload in [&b""[..], b"x", b"hello world\n", &[7u8; 12345][..], &[0u8; 100_000][..]] {
461 for hash in [GitHashKind::Sha1, GitHashKind::Sha256] {
462 assert_eq!(
463 hash.oid_of_object(kind, payload),
464 hash.oid_of(&canonical(kind, payload)),
465 "{kind:?} {} bytes {hash:?}",
466 payload.len()
467 );
468 }
469 }
470 }
471 assert_eq!(
473 hex::encode(GitHashKind::Sha1.oid_of_object(GitObjectKind::Blob, b"")),
474 "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
475 );
476 }
477}