znippy-plugin-git 0.1.1

Git object-store metadata plugin for znippy (native builtin — no WASM). Carries the reserved oid / commit-graph / reachability sub-indexes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Canonical git object bytes — the unit a `git`-format znippy archive stores.
//!
//! An archive entry's payload is the **canonical serialization** of a git
//! object: `"<type> <size>\0<content>"`. That is precisely the byte string a git
//! object id is the hash of, so
//!
//!   * `object_type` and `object_size` are derived from the stored bytes with no
//!     out-of-band state (P-2: the plugin writes only what it can see), and
//!   * a read-back is checkable *by construction* — re-hash the bytes and compare
//!     against the `relative_path`, which is the oid hex. That is an applied
//!     output, not a state round-trip (LAW 2).
//!
//! Nothing here allocates per object beyond the borrow it returns, and nothing
//! panics on malformed input (P-4).

use sha1::Digest as _;

/// The four git object types.
#[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,
        }
    }
}

/// A parsed canonical git object: its type and the content after the NUL.
#[derive(Debug, Clone, Copy)]
pub struct GitObject<'a> {
    pub kind: GitObjectKind,
    pub payload: &'a [u8],
}

/// Parse `"<type> <size>\0<content>"`.
///
/// Returns `None` — never panics — when the header is absent, the type is not
/// one of the four, the size is not decimal, or the declared size disagrees with
/// the actual payload length. That last check is what makes a truncated or
/// padded entry a *miss* rather than a silently wrong `object_size`.
pub fn parse_canonical(data: &[u8]) -> Option<GitObject<'_>> {
    // Bound the header scan: a legal header is "<=6 type + 1 sp + <=20 digits + NUL".
    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 })
}

/// Build canonical bytes from a type and content. Used by tests and by any
/// writer that has the content in hand.
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
}

/// Which hash a repository's object ids use. gunnar defaults to sha256 (D11);
/// sha1 stays supported.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitHashKind {
    Sha1,
    Sha256,
}

impl GitHashKind {
    /// Raw oid length in bytes.
    pub fn oid_len(self) -> usize {
        match self {
            GitHashKind::Sha1 => 20,
            GitHashKind::Sha256 => 32,
        }
    }

    /// Hex oid length — the length of a `relative_path` in a git archive.
    pub fn hex_len(self) -> usize {
        self.oid_len() * 2
    }

    /// Infer the hash from a hex oid's length. `None` for anything else.
    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,
        }
    }

    /// The object id of these canonical bytes.
    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(),
        }
    }

    /// The hex object id of these canonical bytes.
    pub fn oid_hex_of(self, canonical_bytes: &[u8]) -> String {
        hex::encode(self.oid_of(canonical_bytes))
    }
}

/// True when `path` is shaped like a git oid used as a `relative_path`: the
/// whole path is 40 or 64 **lowercase** hex characters. Deliberately strict —
/// git writes lowercase hex everywhere, and accepting mixed case would put two
/// spellings of one object in one archive.
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))
}

/// The two files a packed git object store is made of.
///
/// A `git`-format archive holds **either** loose canonical objects keyed by oid
/// **or** a repository's packs. The second is what gunnar's `repack()` produces
/// and what its cold tier seals: a pack keeps the client's own deflate and its
/// delta chains, both of which an inflated per-oid tier destroys forever.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PackFileKind {
    /// `pack-<id>.pack` — the entries themselves, every one already deflated.
    Data,
    /// `pack-<id>.idx` — the pack's own oid index.
    Index,
}

impl PackFileKind {
    pub fn as_str(self) -> &'static str {
        match self {
            PackFileKind::Data => PACKFILE_TYPE,
            PackFileKind::Index => PACK_INDEX_TYPE,
        }
    }

    /// The magic every file of this kind starts with. Checking it is what makes
    /// a `.pack` name a *claim* rather than a fact.
    pub fn magic(self) -> &'static [u8] {
        match self {
            PackFileKind::Data => b"PACK",
            // pack index v2. v1 has no magic at all and gunnar never writes it.
            PackFileKind::Index => b"\xfftOc",
        }
    }
}

/// `object_type` for a `pack-<id>.pack`.
pub const PACKFILE_TYPE: &str = "packfile";

/// `object_type` for a `pack-<id>.idx`.
pub const PACK_INDEX_TYPE: &str = "pack-index";

/// True when `path` is `pack-<oid hex>.<ext>`, the name git itself gives the
/// files in `objects/pack`, and the name gunnar seals them under.
///
/// **The name matters, and this is why it is checked rather than ignored.**
/// znippy resolves "is this already compressed?" by extension first and by a
/// magic probe only as a fallback, and the probe reads offset 0 of *every*
/// chunk while a container's magic sits at offset 0 of chunk 0 alone. Storing a
/// pack under a name with no extension therefore costs a full codec pass over
/// all but the first chunk — measured at 35.6 s of CPU and 2.6 GB of RSS on
/// 200 MB, against 0.09 s and 184 MB once the name carried the extension. A
/// pack-tier archive keeps git's own file names for exactly that reason.
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)
}

/// One entry of a tree object.
#[derive(Debug, Clone)]
pub struct TreeEntry<'a> {
    pub mode: &'a [u8],
    pub name: &'a [u8],
    pub oid: &'a [u8],
}

/// Parse a tree payload: a sequence of `<mode> <name>\0<raw oid>`.
///
/// Stops at the first malformed record and returns what it read so far — a
/// corrupt tree degrades the reachability closure, it never panics (P-4).
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
}

/// The header fields of a commit payload that the commit graph needs.
#[derive(Debug, Clone, Default)]
pub struct CommitHeader {
    pub tree: Option<String>,
    pub parents: Vec<String>,
    /// Committer timestamp, unix seconds. `None` when absent or unparsable.
    pub committer_time: Option<i64>,
}

/// Parse a commit payload's header block (everything before the first blank
/// line). Unknown headers are ignored; a malformed one is skipped (P-4).
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
}

/// `"Name <email> 1712345678 +0200"` → `1712345678`.
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() {
        // Ground truth: `printf '' | git hash-object -t blob --stdin` is the
        // well-known empty-blob sha1.
        let c = canonical(GitObjectKind::Blob, b"");
        assert_eq!(c, b"blob 0\0");
        assert_eq!(
            GitHashKind::Sha1.oid_hex_of(&c),
            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
        );
        // And the sha256 counterpart, from git's own sha256 test vectors.
        assert_eq!(
            GitHashKind::Sha256.oid_hex_of(&c),
            "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813"
        );
    }

    #[test]
    fn hash_object_matches_git_for_nonempty_blob() {
        // `echo -n 'hello' | git hash-object --stdin`
        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() {
        // Declared 5, actual 4 — a truncated entry must be a miss, not a wrong size.
        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]);

        // Truncated tail: the good record still comes back, no panic.
        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));
    }
}