Skip to main content

znippy_plugin_git/
object.rs

1//! Canonical git object bytes — the unit a `git`-format znippy archive stores.
2//!
3//! An archive entry's payload is the **canonical serialization** of a git
4//! object: `"<type> <size>\0<content>"`. That is precisely the byte string a git
5//! object id is the hash of, so
6//!
7//!   * `object_type` and `object_size` are derived from the stored bytes with no
8//!     out-of-band state (P-2: the plugin writes only what it can see), and
9//!   * a read-back is checkable *by construction* — re-hash the bytes and compare
10//!     against the `relative_path`, which is the oid hex. That is an applied
11//!     output, not a state round-trip (LAW 2).
12//!
13//! Nothing here allocates per object beyond the borrow it returns, and nothing
14//! panics on malformed input (P-4).
15
16use sha1::Digest as _;
17
18/// The four git object types.
19#[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/// A parsed canonical git object: its type and the content after the NUL.
49#[derive(Debug, Clone, Copy)]
50pub struct GitObject<'a> {
51    pub kind: GitObjectKind,
52    pub payload: &'a [u8],
53}
54
55/// Parse `"<type> <size>\0<content>"`.
56///
57/// Returns `None` — never panics — when the header is absent, the type is not
58/// one of the four, the size is not decimal, or the declared size disagrees with
59/// the actual payload length. That last check is what makes a truncated or
60/// padded entry a *miss* rather than a silently wrong `object_size`.
61pub fn parse_canonical(data: &[u8]) -> Option<GitObject<'_>> {
62    // Bound the header scan: a legal header is "<=6 type + 1 sp + <=20 digits + NUL".
63    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
80/// Build canonical bytes from a type and content. Used by tests and by any
81/// writer that has the content in hand.
82pub 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/// Which hash a repository's object ids use. gunnar defaults to sha256 (D11);
93/// sha1 stays supported.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum GitHashKind {
96    Sha1,
97    Sha256,
98}
99
100impl GitHashKind {
101    /// Raw oid length in bytes.
102    pub fn oid_len(self) -> usize {
103        match self {
104            GitHashKind::Sha1 => 20,
105            GitHashKind::Sha256 => 32,
106        }
107    }
108
109    /// Hex oid length — the length of a `relative_path` in a git archive.
110    pub fn hex_len(self) -> usize {
111        self.oid_len() * 2
112    }
113
114    /// Infer the hash from a hex oid's length. `None` for anything else.
115    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    /// The object id of these canonical bytes.
139    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    /// The hex object id of these canonical bytes.
147    pub fn oid_hex_of(self, canonical_bytes: &[u8]) -> String {
148        hex::encode(self.oid_of(canonical_bytes))
149    }
150}
151
152/// True when `path` is shaped like a git oid used as a `relative_path`: the
153/// whole path is 40 or 64 **lowercase** hex characters. Deliberately strict —
154/// git writes lowercase hex everywhere, and accepting mixed case would put two
155/// spellings of one object in one archive.
156pub fn is_oid_path(path: &str) -> bool {
157    matches!(path.len(), 40 | 64) && path.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
158}
159
160/// The two files a packed git object store is made of.
161///
162/// A `git`-format archive holds **either** loose canonical objects keyed by oid
163/// **or** a repository's packs. The second is what gunnar's `repack()` produces
164/// and what its cold tier seals: a pack keeps the client's own deflate and its
165/// delta chains, both of which an inflated per-oid tier destroys forever.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
167pub enum PackFileKind {
168    /// `pack-<id>.pack` — the entries themselves, every one already deflated.
169    Data,
170    /// `pack-<id>.idx` — the pack's own oid index.
171    Index,
172}
173
174impl PackFileKind {
175    pub fn as_str(self) -> &'static str {
176        match self {
177            PackFileKind::Data => PACKFILE_TYPE,
178            PackFileKind::Index => PACK_INDEX_TYPE,
179        }
180    }
181
182    /// The magic every file of this kind starts with. Checking it is what makes
183    /// a `.pack` name a *claim* rather than a fact.
184    pub fn magic(self) -> &'static [u8] {
185        match self {
186            PackFileKind::Data => b"PACK",
187            // pack index v2. v1 has no magic at all and gunnar never writes it.
188            PackFileKind::Index => b"\xfftOc",
189        }
190    }
191}
192
193/// `object_type` for a `pack-<id>.pack`.
194pub const PACKFILE_TYPE: &str = "packfile";
195
196/// `object_type` for a `pack-<id>.idx`.
197pub const PACK_INDEX_TYPE: &str = "pack-index";
198
199/// True when `path` is `pack-<oid hex>.<ext>`, the name git itself gives the
200/// files in `objects/pack`, and the name gunnar seals them under.
201///
202/// **The name matters, and this is why it is checked rather than ignored.**
203/// znippy resolves "is this already compressed?" by extension first and by a
204/// magic probe only as a fallback, and the probe reads offset 0 of *every*
205/// chunk while a container's magic sits at offset 0 of chunk 0 alone. Storing a
206/// pack under a name with no extension therefore costs a full codec pass over
207/// all but the first chunk — measured at 35.6 s of CPU and 2.6 GB of RSS on
208/// 200 MB, against 0.09 s and 184 MB once the name carried the extension. A
209/// pack-tier archive keeps git's own file names for exactly that reason.
210pub fn pack_path_kind(path: &str) -> Option<PackFileKind> {
211    let name = path.rsplit('/').next().unwrap_or(path);
212    let stem = name.strip_prefix("pack-")?;
213    let (id, kind) = if let Some(id) = stem.strip_suffix(".pack") {
214        (id, PackFileKind::Data)
215    } else {
216        (stem.strip_suffix(".idx")?, PackFileKind::Index)
217    };
218    is_oid_path(id).then_some(kind)
219}
220
221/// One entry of a tree object.
222#[derive(Debug, Clone)]
223pub struct TreeEntry<'a> {
224    pub mode: &'a [u8],
225    pub name: &'a [u8],
226    pub oid: &'a [u8],
227}
228
229/// Parse a tree payload: a sequence of `<mode> <name>\0<raw oid>`.
230///
231/// Stops at the first malformed record and returns what it read so far — a
232/// corrupt tree degrades the reachability closure, it never panics (P-4).
233pub fn tree_entries(payload: &[u8], oid_len: usize) -> Vec<TreeEntry<'_>> {
234    let mut out = Vec::new();
235    let mut i = 0usize;
236    while i < payload.len() {
237        let Some(sp_rel) = payload[i..].iter().position(|&b| b == b' ') else { break };
238        let sp = i + sp_rel;
239        let Some(nul_rel) = payload[sp + 1..].iter().position(|&b| b == 0) else { break };
240        let nul = sp + 1 + nul_rel;
241        let end = nul + 1 + oid_len;
242        if end > payload.len() {
243            break;
244        }
245        out.push(TreeEntry {
246            mode: &payload[i..sp],
247            name: &payload[sp + 1..nul],
248            oid: &payload[nul + 1..end],
249        });
250        i = end;
251    }
252    out
253}
254
255/// The header fields of a commit payload that the commit graph needs.
256#[derive(Debug, Clone, Default)]
257pub struct CommitHeader {
258    pub tree: Option<String>,
259    pub parents: Vec<String>,
260    /// Committer timestamp, unix seconds. `None` when absent or unparsable.
261    pub committer_time: Option<i64>,
262}
263
264/// Parse a commit payload's header block (everything before the first blank
265/// line). Unknown headers are ignored; a malformed one is skipped (P-4).
266pub fn parse_commit(payload: &[u8]) -> CommitHeader {
267    let mut h = CommitHeader::default();
268    let mut rest = payload;
269    loop {
270        let line_end = match rest.iter().position(|&b| b == b'\n') {
271            Some(p) => p,
272            None => rest.len(),
273        };
274        let line = &rest[..line_end];
275        if line.is_empty() {
276            break;
277        }
278        if let Some(v) = line.strip_prefix(b"tree ") {
279            if let Ok(s) = std::str::from_utf8(v) {
280                if is_oid_path(s) {
281                    h.tree = Some(s.to_string());
282                }
283            }
284        } else if let Some(v) = line.strip_prefix(b"parent ") {
285            if let Ok(s) = std::str::from_utf8(v) {
286                if is_oid_path(s) {
287                    h.parents.push(s.to_string());
288                }
289            }
290        } else if let Some(v) = line.strip_prefix(b"committer ") {
291            h.committer_time = committer_timestamp(v);
292        }
293        if line_end >= rest.len() {
294            break;
295        }
296        rest = &rest[line_end + 1..];
297    }
298    h
299}
300
301/// `"Name <email> 1712345678 +0200"` → `1712345678`.
302fn committer_timestamp(v: &[u8]) -> Option<i64> {
303    let s = std::str::from_utf8(v).ok()?;
304    let gt = s.rfind('>')?;
305    let mut it = s[gt + 1..].split_ascii_whitespace();
306    it.next()?.parse::<i64>().ok()
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn canonical_roundtrips_and_hashes_like_git() {
315        // Ground truth: `printf '' | git hash-object -t blob --stdin` is the
316        // well-known empty-blob sha1.
317        let c = canonical(GitObjectKind::Blob, b"");
318        assert_eq!(c, b"blob 0\0");
319        assert_eq!(
320            GitHashKind::Sha1.oid_hex_of(&c),
321            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
322        );
323        // And the sha256 counterpart, from git's own sha256 test vectors.
324        assert_eq!(
325            GitHashKind::Sha256.oid_hex_of(&c),
326            "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813"
327        );
328    }
329
330    #[test]
331    fn hash_object_matches_git_for_nonempty_blob() {
332        // `echo -n 'hello' | git hash-object --stdin`
333        let c = canonical(GitObjectKind::Blob, b"hello");
334        assert_eq!(
335            GitHashKind::Sha1.oid_hex_of(&c),
336            "b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0"
337        );
338    }
339
340    #[test]
341    fn parse_rejects_size_mismatch_instead_of_lying() {
342        // Declared 5, actual 4 — a truncated entry must be a miss, not a wrong size.
343        assert!(parse_canonical(b"blob 5\0abcd").is_none());
344        assert!(parse_canonical(b"blob 4\0abcd").is_some());
345    }
346
347    #[test]
348    fn parse_never_panics_on_garbage() {
349        for bad in [
350            &b""[..],
351            &b"\0"[..],
352            &b"blob"[..],
353            &b"blob \0"[..],
354            &b"nope 3\0abc"[..],
355            &b"blob -1\0abc"[..],
356            &b"blob 99999999999999999999999999\0abc"[..],
357            &[0xffu8; 300][..],
358        ] {
359            assert!(parse_canonical(bad).is_none(), "should not parse: {bad:?}");
360        }
361    }
362
363    #[test]
364    fn oid_path_shape_is_strict() {
365        assert!(is_oid_path(&"a".repeat(40)));
366        assert!(is_oid_path(&"0".repeat(64)));
367        assert!(!is_oid_path(&"A".repeat(40)), "uppercase hex is not a git oid path");
368        assert!(!is_oid_path(&"a".repeat(41)));
369        assert!(!is_oid_path("objects/aa/bb"));
370        assert!(!is_oid_path(&"g".repeat(40)));
371    }
372
373    #[test]
374    fn tree_entries_parse_and_tolerate_truncation() {
375        let mut payload = Vec::new();
376        payload.extend_from_slice(b"100644 a.txt\0");
377        payload.extend_from_slice(&[0x11u8; 20]);
378        payload.extend_from_slice(b"40000 sub\0");
379        payload.extend_from_slice(&[0x22u8; 20]);
380        let e = tree_entries(&payload, 20);
381        assert_eq!(e.len(), 2);
382        assert_eq!(e[0].name, b"a.txt");
383        assert_eq!(e[1].oid, &[0x22u8; 20]);
384
385        // Truncated tail: the good record still comes back, no panic.
386        let t = tree_entries(&payload[..payload.len() - 5], 20);
387        assert_eq!(t.len(), 1);
388    }
389
390    #[test]
391    fn commit_header_parses_tree_parents_and_time() {
392        let payload = concat!(
393            "tree 1111111111111111111111111111111111111111\n",
394            "parent 2222222222222222222222222222222222222222\n",
395            "parent 3333333333333333333333333333333333333333\n",
396            "author A <a@x> 1700000000 +0100\n",
397            "committer C <c@x> 1700000123 +0200\n",
398            "\n",
399            "message body\n",
400        );
401        let h = parse_commit(payload.as_bytes());
402        assert_eq!(h.tree.as_deref(), Some("1111111111111111111111111111111111111111"));
403        assert_eq!(h.parents.len(), 2);
404        assert_eq!(h.committer_time, Some(1700000123));
405    }
406}