Skip to main content

znippy_plugin_git/
lib.rs

1//! znippy handler for **git object stores** — the `git` package format.
2//!
3//! One archive holds one git repository (D12: one `.znippy` per repo). The
4//! consumer is `gunnar`, a pure-Rust git server whose `repack()` writes the cold
5//! tier.
6//!
7//! ## The two tiers a repository can be stored as
8//!
9//! | tier | data entries | what it preserves |
10//! |---|---|---|
11//! | **objects** | one per object, named by its **oid hex**, holding its canonical bytes `"<type> <size>\0<content>"` | the oid is checkable by re-hashing the entry |
12//! | **packs** | `pack-<id>.pack` + `pack-<id>.idx` | the client's own **deflate** and every **delta chain** |
13//!
14//! The object tier is the one this format was designed around and is what the
15//! `__gunnar_*` sub-indexes describe. **The pack tier is what gunnar actually
16//! seals**, and the reason is measured: a git object in a pack is already
17//! deflated and often a delta, so storing it inflated per oid costs an OpenZL
18//! decode plus a fresh zlib deflate on every fetch, for ever, and forecloses
19//! pack-copy permanently. gunnar measured an archive of 60000 small git objects
20//! at 3.4× *larger* than its input. See [`sections::GitIndexBuilder::pack_tier`].
21//!
22//! ## What this format adds on top of a plain archive
23//!
24//! | column / module | carries |
25//! |---|---|
26//! | `object_type` | `blob` / `tree` / `commit` / `tag` for the object tier; `packfile` / `pack-index` for the pack tier — typed listing with no reads |
27//! | `object_size` | content length, so quota gates and size analytics are index-only |
28//! | `__gunnar_oid__` | an `stree` over the first 8 bytes of every oid → its lookup row |
29//! | `__gunnar_graph__` | the commit graph as Arrow: `oid, parents[], tree, committer_time, generation` |
30//! | `__gunnar_reach__` | per-commit reachability bitmaps (roaring) over object ordinals |
31//! | `__gunnar_refs__` / `__gunnar_secrets__` | the server's push logs, on **either** tier |
32//!
33//! The last three object modules are emitted for the object tier only — a pack
34//! carries its own `.idx`, which is a better oid index than `__gunnar_oid__`
35//! because it addresses pack offsets.
36//!
37//! All of these modules are **reserved** (`znippy_common::is_reserved_module`),
38//! so `znippy list`, `decompress`, the iceberg sink and the manifest readers skip
39//! them exactly as they skip the lookup and the trie.
40//!
41//! ## Laws it honours
42//!
43//! **P-1** one archive = one ecosystem (`--format git`); **P-2** it writes only
44//! the columns it declared; **P-3** it is discovered through [`meta`]; **P-4** a
45//! malformed or hostile entry never panics — `object_type` degrades to
46//! `unknown` and the archive still writes.
47//!
48//! ## Who writes the reserved sections
49//!
50//! [`GitIndexBuilder`] does, handed to `ArrowIpcSink::with_reserved_builder` by
51//! the writer that knows the object set — gunnar's `repack()`. It is deliberately
52//! **not** wired into `znippy compress --format git`: the CLI's small-file batch
53//! pass never sees objects that take the big-file path, so a CLI-built index
54//! would be silently incomplete, and a silently incomplete oid index is worse
55//! than none. `znippy compress --format git` therefore writes the two columns and
56//! no reserved sections; `znippy run git lookup|graph` reads archives that carry
57//! them.
58//!
59//! Native builtin, registered in `znippy-cli/src/handlers.rs::builtin_handlers`.
60//! Deliberately **not** a WASM plugin: the oid index's hot path is `stree`, which
61//! needs AVX2, an mmap and prefetch, none of which wasm offers.
62
63use std::collections::HashMap;
64
65use znippy_common::arrow::datatypes::{DataType, Field};
66use znippy_common::plugin::{
67    ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta,
68};
69
70/// The measured workloads behind the `znippy.git_*` benches (feature
71/// `bench-kernels`). Not linked into a shipped archiver.
72#[cfg(feature = "bench-kernels")]
73pub mod bench_kernels;
74pub mod graph;
75pub mod object;
76pub mod oid_index;
77pub mod pushlog;
78pub mod reach;
79pub mod refs;
80pub mod secrets;
81pub mod sections;
82
83pub use graph::{CommitNode, decode_graph, graph_schema, read_graph};
84pub use object::{
85    GitHashKind, GitObject, GitObjectKind, PACKFILE_TYPE, PACK_INDEX_TYPE, PackFileKind, canonical,
86    is_oid_path, pack_path_kind, parse_canonical, parse_commit, tree_entries,
87};
88pub use oid_index::{GitOidIndex, OidEntry, OidHit, key_for_oid};
89pub use pushlog::{
90    CompactionPolicy, CompactionReport, Finish, PushLog, PushLogScan, scan_frames,
91};
92pub use reach::{ReachEntry, ReachPolicy, decode_reach, read_reach, reach_schema};
93pub use refs::{RefLog, RefState, RefUpdate, read_refs, refs_schema};
94pub use secrets::{SecretState, SecretUpdate, SecretsLog, read_secrets, secrets_schema};
95pub use sections::GitIndexBuilder;
96
97/// DenseUnion / `pkg_type` discriminant. Clear of the built-ins (1–25), media
98/// (25), skidbladnir (40) and rust-toolchain (41).
99pub const GIT_TYPE_ID: i8 = 42;
100
101/// `object_type` written when the entry is not parseable as a canonical git
102/// object. A distinct, queryable state — never a silent `blob`.
103pub const UNKNOWN_OBJECT_TYPE: &str = "unknown";
104
105/// Native git-object handler.
106pub struct NativeGitPlugin;
107
108impl NativeGitPlugin {
109    pub fn new() -> Self {
110        NativeGitPlugin
111    }
112}
113
114impl Default for NativeGitPlugin {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl ArchiveTypePlugin for NativeGitPlugin {
121    fn name(&self) -> &str {
122        "git"
123    }
124
125    fn type_id(&self) -> i8 {
126        GIT_TYPE_ID
127    }
128
129    fn meta(&self) -> HandlerMeta {
130        HandlerMeta {
131            name: "git".into(),
132            aliases: vec!["gunnar".into(), "git-objects".into()],
133            type_id: GIT_TYPE_ID,
134            ecosystem: "Git object store (gunnar cold tier — one archive per repository)".into(),
135            // Left empty on purpose, and it is not an oversight. A loose git
136            // object carries no extension at all — its name IS its oid — and
137            // the pack entries this handler also claims are matched on their
138            // whole `pack-<oid>.<ext>` shape, not on a suffix. An extension
139            // list here would claim every `.pack` and `.idx` in any archive,
140            // which is wider than the truth. `matches_path` is the authority.
141            extensions: Vec::new(),
142            description: "Stores a git repository: either canonical objects keyed by oid hex, \
143                          with the reserved __gunnar_oid__ (stree) / __gunnar_graph__ (commit \
144                          graph) / __gunnar_reach__ (reachability bitmaps) sub-indexes, or the \
145                          pack tier (pack-<id>.pack / .idx) that preserves the client's deflate \
146                          and its delta chains"
147                .into(),
148            commands: vec![
149                HandlerCommand::new(
150                    "inspect",
151                    "Print type/size/sha1/sha256 for a file of canonical git object bytes",
152                ),
153                HandlerCommand::new(
154                    "lookup",
155                    "Resolve an oid against an archive's __gunnar_oid__ index: `git lookup <archive> <oid>`",
156                ),
157                HandlerCommand::new(
158                    "graph",
159                    "Print an archive's commit graph (oid, generation, time, parents)",
160                ),
161            ],
162        }
163    }
164
165    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
166        match cmd {
167            "inspect" => {
168                let path = args
169                    .first()
170                    .ok_or_else(|| anyhow::anyhow!("usage: git inspect <object-file>"))?;
171                let data = std::fs::read(path)?;
172                match parse_canonical(&data) {
173                    Some(obj) => {
174                        println!("type:   {}", obj.kind.as_str());
175                        println!("size:   {}", obj.payload.len());
176                        println!("sha1:   {}", GitHashKind::Sha1.oid_hex_of(&data));
177                        println!("sha256: {}", GitHashKind::Sha256.oid_hex_of(&data));
178                        Ok(())
179                    }
180                    None => anyhow::bail!(
181                        "'{path}' is not canonical git object bytes (`<type> <size>\\0<content>`)"
182                    ),
183                }
184            }
185            "lookup" => {
186                let (archive, oid) = match (args.first(), args.get(1)) {
187                    (Some(a), Some(o)) => (a, o),
188                    _ => anyhow::bail!("usage: git lookup <archive> <oid-hex>"),
189                };
190                let index = GitOidIndex::open(std::path::Path::new(archive))?.ok_or_else(|| {
191                    anyhow::anyhow!("'{archive}' carries no __gunnar_oid__ index")
192                })?;
193                match index.lookup_hex(oid) {
194                    Some(hit) => {
195                        println!("oid:        {oid}");
196                        println!("lookup_row: {}", hit.lookup_row);
197                        println!("ordinal:    {}", hit.ordinal);
198                        Ok(())
199                    }
200                    None => anyhow::bail!("oid '{oid}' is not in '{archive}'"),
201                }
202            }
203            "graph" => {
204                let archive = args
205                    .first()
206                    .ok_or_else(|| anyhow::anyhow!("usage: git graph <archive>"))?;
207                let nodes = read_graph(std::path::Path::new(archive))?.ok_or_else(|| {
208                    anyhow::anyhow!("'{archive}' carries no __gunnar_graph__ section")
209                })?;
210                println!("commits: {}", nodes.len());
211                for n in &nodes {
212                    println!(
213                        "  {} gen={} time={} parents=[{}]",
214                        n.oid,
215                        n.generation,
216                        n.committer_time.map(|t| t.to_string()).unwrap_or_else(|| "-".into()),
217                        n.parents.join(" ")
218                    );
219                }
220                Ok(())
221            }
222            other => anyhow::bail!("git: unknown subcommand '{other}'"),
223        }
224    }
225
226    /// A git archive's entries are either **oids** — the whole `relative_path`
227    /// is 40 or 64 lowercase hex characters — or the **packs** of a repository's
228    /// cold tier, `pack-<id>.pack` / `pack-<id>.idx`.
229    ///
230    /// Both are the same ecosystem and so belong to one handler (P-1). Which of
231    /// the two an archive holds is not a variant of the format: it is what the
232    /// writer had. gunnar's `repack()` produces a consolidated pack and seals
233    /// that, because a pack preserves the pushing client's own deflate and its
234    /// delta chains, and an inflated per-oid tier destroys both permanently.
235    fn matches_path(&self, path: &str) -> bool {
236        is_oid_path(path) || pack_path_kind(path).is_some()
237    }
238
239    fn schema_fields(&self) -> Vec<Field> {
240        vec![
241            Field::new("object_type", DataType::Utf8, true),
242            // UInt32: the index's extension-column writer materialises UInt32 and
243            // Utf8 only. A git object larger than 4 GiB saturates here; its exact
244            // byte length is always available from the base `uncompressed_size`
245            // column, which is u64 and is the authority.
246            Field::new("object_size", DataType::UInt32, true),
247        ]
248    }
249
250    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
251        if !self.matches_path(path) {
252            return None;
253        }
254        let mut f: HashMap<String, ExtensionValue> = HashMap::new();
255
256        // A pack is typed by its magic, never by its name. A name is a claim
257        // the writer made; the magic is what the bytes are. `pack-<hex>.pack`
258        // over something that is not a packfile degrades to `unknown` exactly
259        // as malformed object bytes do (P-4) — a wrong type in the index is
260        // worse than an honest absence, because it is queried and believed.
261        if let Some(kind) = pack_path_kind(path) {
262            let typed = if data.starts_with(kind.magic()) {
263                kind.as_str()
264            } else {
265                UNKNOWN_OBJECT_TYPE
266            };
267            f.insert("object_type".into(), ExtensionValue::Str(typed.into()));
268            f.insert(
269                "object_size".into(),
270                ExtensionValue::U32(data.len().min(u32::MAX as usize) as u32),
271            );
272            return Some(ExtensionRow { fields: f });
273        }
274
275        match parse_canonical(data) {
276            Some(obj) => {
277                f.insert("object_type".into(), ExtensionValue::Str(obj.kind.as_str().into()));
278                f.insert(
279                    "object_size".into(),
280                    ExtensionValue::U32(obj.payload.len().min(u32::MAX as usize) as u32),
281                );
282            }
283            None => {
284                // P-4: a decompress bomb, a truncated object or plain garbage
285                // still writes a row — typed `unknown`, sized by what is there.
286                f.insert(
287                    "object_type".into(),
288                    ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into()),
289                );
290                f.insert(
291                    "object_size".into(),
292                    ExtensionValue::U32(data.len().min(u32::MAX as usize) as u32),
293                );
294            }
295        }
296        Some(ExtensionRow { fields: f })
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::object::canonical;
304
305    fn get<'a>(row: &'a ExtensionRow, k: &str) -> Option<&'a ExtensionValue> {
306        row.fields.get(k)
307    }
308
309    #[test]
310    fn claims_oid_paths_and_nothing_else() {
311        let p = NativeGitPlugin::new();
312        assert!(p.matches_path(&"a1b2c3d4".repeat(5)));       // 40 hex
313        assert!(p.matches_path(&"0f".repeat(32)));            // 64 hex
314        assert!(!p.matches_path("refs/heads/main"));
315        assert!(!p.matches_path("objects/pack/pack-abc.pack"));
316        assert!(!p.matches_path(&"A1B2C3D4".repeat(5)));
317    }
318
319    /// The pack tier's two file names are claimed, and only in the shape git
320    /// itself writes. A bare `.pack` suffix is not enough: an archive of
321    /// arbitrary files must not have its entries retyped as git packs.
322    #[test]
323    fn claims_the_pack_tier_by_its_whole_name_not_by_a_suffix() {
324        let p = NativeGitPlugin::new();
325        let id40 = "a1b2c3d4".repeat(5);
326        let id64 = "0f".repeat(32);
327        assert_eq!(pack_path_kind(&format!("pack-{id40}.pack")), Some(PackFileKind::Data));
328        assert_eq!(pack_path_kind(&format!("pack-{id64}.idx")), Some(PackFileKind::Index));
329        assert!(p.matches_path(&format!("pack-{id64}.pack")));
330        assert!(p.matches_path(&format!("objects/pack/pack-{id40}.idx")));
331
332        for not_ours in [
333            "pack-nothex.pack",
334            "somefile.pack",
335            "pack-.pack",
336            &format!("pack-{id40}.bitmap"),
337            &format!("pack-{}.pack", "A1B2C3D4".repeat(5)),
338            &format!("{id40}.pack"),
339        ] {
340            assert_eq!(pack_path_kind(not_ours), None, "wrongly claimed {not_ours}");
341        }
342        assert!(!p.matches_path("somefile.pack"));
343    }
344
345    /// A pack is typed by its **magic**, never by the name the writer chose.
346    /// A file called `pack-….pack` that does not start with `PACK` is
347    /// `unknown` — the same honest state malformed object bytes get (P-4).
348    #[test]
349    fn a_pack_is_typed_by_its_magic_and_a_liar_is_unknown() {
350        let p = NativeGitPlugin::new();
351        let id = "0f".repeat(32);
352
353        let mut pack = b"PACK".to_vec();
354        pack.extend_from_slice(&2u32.to_be_bytes());
355        pack.extend_from_slice(&7u32.to_be_bytes());
356        let row = p.extract_metadata(&format!("pack-{id}.pack"), &pack).unwrap();
357        assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str(PACKFILE_TYPE.into())));
358        assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(pack.len() as u32)));
359
360        let idx = b"\xfftOc\x00\x00\x00\x02".to_vec();
361        let row = p.extract_metadata(&format!("pack-{id}.idx"), &idx).unwrap();
362        assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str(PACK_INDEX_TYPE.into())));
363
364        // Now the liars. Each is named as a pack and is not one.
365        for (name, bytes) in [
366            (format!("pack-{id}.pack"), &b"NOTAPACK"[..]),
367            (format!("pack-{id}.idx"), &b"PACK\0\0\0\x02"[..]),
368            (format!("pack-{id}.pack"), &b""[..]),
369        ] {
370            let row = p.extract_metadata(&name, bytes).expect("a claimed path always yields a row");
371            assert_eq!(
372                get(&row, "object_type"),
373                Some(&ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into())),
374                "{name} carries {bytes:?}, which is not that kind of file — it must not be typed \
375                 from its name"
376            );
377        }
378    }
379
380    #[test]
381    fn types_and_sizes_come_from_the_stored_bytes() {
382        let p = NativeGitPlugin::new();
383        let body = b"hello world";
384        let bytes = canonical(GitObjectKind::Blob, body);
385        let oid = GitHashKind::Sha256.oid_hex_of(&bytes);
386        let row = p.extract_metadata(&oid, &bytes).unwrap();
387        assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str("blob".into())));
388        assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(body.len() as u32)));
389
390        let commit = canonical(GitObjectKind::Commit, b"tree x\n\nmsg\n");
391        let coid = GitHashKind::Sha1.oid_hex_of(&commit);
392        let crow = p.extract_metadata(&coid, &commit).unwrap();
393        assert_eq!(get(&crow, "object_type"), Some(&ExtensionValue::Str("commit".into())));
394    }
395
396    #[test]
397    fn garbage_degrades_to_unknown_and_never_panics() {
398        let p = NativeGitPlugin::new();
399        let oid = "d".repeat(64);
400        for bad in [&b""[..], &b"\0\0\0"[..], &[0xffu8; 4096][..], &b"blob 99\0short"[..]] {
401            let row = p.extract_metadata(&oid, bad).expect("a claimed path always yields a row");
402            assert_eq!(
403                get(&row, "object_type"),
404                Some(&ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into())),
405                "unparseable bytes must be typed `unknown`, never guessed"
406            );
407            assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(bad.len() as u32)));
408        }
409    }
410
411    #[test]
412    fn a_path_that_is_not_an_oid_yields_no_row() {
413        let p = NativeGitPlugin::new();
414        assert!(p.extract_metadata("HEAD", b"ref: refs/heads/main\n").is_none());
415    }
416
417    #[test]
418    fn meta_is_the_discovery_record() {
419        let m = NativeGitPlugin::new().meta();
420        assert_eq!(m.name, "git");
421        assert_eq!(m.type_id, GIT_TYPE_ID);
422        assert!(m.aliases.contains(&"gunnar".to_string()));
423        assert_eq!(m.commands.len(), 3);
424    }
425}