Skip to main content

netget/server/git/
pack.rs

1//! Git object and pack-file construction.
2//!
3//! The LLM describes a repository as ordinary structured data - a branch name, a commit
4//! message and a list of `{path, content}` files - and this module turns that into real Git
5//! objects: blobs, trees, a commit, and a version-2 pack file. Object IDs are *computed*
6//! here, never supplied by the model, which is what makes `git clone` able to succeed: the
7//! SHA advertised by `GET /info/refs` and the SHA of the commit inside the pack returned by
8//! `POST /git-upload-pack` are derived from the same bytes by the same code.
9//!
10//! Nothing is written to disk and no repository state is kept between requests. Each request
11//! rebuilds the objects from the snapshot the model (or a static/script handler) provided for
12//! that request; see `CLAUDE.md` for what that implies about determinism.
13//!
14//! Deliberately not implemented: deltas (every object is stored whole), ofs-delta/ref-delta
15//! parsing, thin packs, shallow clones, annotated tag objects, submodules and symlinks.
16
17use anyhow::{bail, Result};
18use std::collections::BTreeMap;
19
20/// SHA-1, implemented here because the `sha1` crate is a dev-dependency of this workspace and
21/// is not linked into the binary. Git object IDs and the pack trailer are SHA-1 by definition
22/// (this server does not implement the SHA-256 object format), so the algorithm is required,
23/// not chosen. Verified against the standard test vectors in `tests/` and against the real
24/// `git hash-object`.
25#[derive(Clone)]
26struct Sha1 {
27    state: [u32; 5],
28    buffer: [u8; 64],
29    buffered: usize,
30    length_bits: u64,
31}
32
33impl Sha1 {
34    fn new() -> Self {
35        Self {
36            state: [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0],
37            buffer: [0u8; 64],
38            buffered: 0,
39            length_bits: 0,
40        }
41    }
42
43    fn update(&mut self, mut data: &[u8]) {
44        self.length_bits = self.length_bits.wrapping_add((data.len() as u64) * 8);
45
46        if self.buffered > 0 {
47            let take = std::cmp::min(64 - self.buffered, data.len());
48            self.buffer[self.buffered..self.buffered + take].copy_from_slice(&data[..take]);
49            self.buffered += take;
50            data = &data[take..];
51            if self.buffered == 64 {
52                let block = self.buffer;
53                self.compress(&block);
54                self.buffered = 0;
55            }
56        }
57
58        let mut chunks = data.chunks_exact(64);
59        for chunk in &mut chunks {
60            let mut block = [0u8; 64];
61            block.copy_from_slice(chunk);
62            self.compress(&block);
63        }
64
65        let rest = chunks.remainder();
66        if !rest.is_empty() {
67            self.buffer[..rest.len()].copy_from_slice(rest);
68            self.buffered = rest.len();
69        }
70    }
71
72    fn finalize(mut self) -> [u8; 20] {
73        let length_bits = self.length_bits;
74
75        // 0x80 then zeros until 56 bytes mod 64, then the 64-bit big-endian bit length.
76        self.update_raw(&[0x80]);
77        while self.buffered != 56 {
78            self.update_raw(&[0x00]);
79        }
80        self.update_raw(&length_bits.to_be_bytes());
81
82        let mut out = [0u8; 20];
83        for (i, word) in self.state.iter().enumerate() {
84            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
85        }
86        out
87    }
88
89    /// Feed padding bytes without counting them towards the message length.
90    fn update_raw(&mut self, data: &[u8]) {
91        for &byte in data {
92            self.buffer[self.buffered] = byte;
93            self.buffered += 1;
94            if self.buffered == 64 {
95                let block = self.buffer;
96                self.compress(&block);
97                self.buffered = 0;
98            }
99        }
100    }
101
102    fn compress(&mut self, block: &[u8; 64]) {
103        let mut w = [0u32; 80];
104        for (i, word) in w.iter_mut().take(16).enumerate() {
105            *word = u32::from_be_bytes([
106                block[i * 4],
107                block[i * 4 + 1],
108                block[i * 4 + 2],
109                block[i * 4 + 3],
110            ]);
111        }
112        for i in 16..80 {
113            w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
114        }
115
116        let [mut a, mut b, mut c, mut d, mut e] = self.state;
117
118        for (i, &word) in w.iter().enumerate() {
119            let (f, k) = match i {
120                0..=19 => ((b & c) | ((!b) & d), 0x5A827999u32),
121                20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
122                40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
123                _ => (b ^ c ^ d, 0xCA62C1D6),
124            };
125            let temp = a
126                .rotate_left(5)
127                .wrapping_add(f)
128                .wrapping_add(e)
129                .wrapping_add(k)
130                .wrapping_add(word);
131            e = d;
132            d = c;
133            c = b.rotate_left(30);
134            b = a;
135            a = temp;
136        }
137
138        self.state[0] = self.state[0].wrapping_add(a);
139        self.state[1] = self.state[1].wrapping_add(b);
140        self.state[2] = self.state[2].wrapping_add(c);
141        self.state[3] = self.state[3].wrapping_add(d);
142        self.state[4] = self.state[4].wrapping_add(e);
143    }
144}
145
146/// Convenience: SHA-1 of a single buffer.
147pub fn sha1(data: &[u8]) -> [u8; 20] {
148    let mut hasher = Sha1::new();
149    hasher.update(data);
150    hasher.finalize()
151}
152
153/// Object types that appear in a pack, with their pack type IDs.
154#[derive(Clone, Copy, PartialEq, Eq, Debug)]
155pub enum ObjectKind {
156    Commit = 1,
157    Tree = 2,
158    Blob = 3,
159}
160
161impl ObjectKind {
162    fn header_name(self) -> &'static str {
163        match self {
164            ObjectKind::Commit => "commit",
165            ObjectKind::Tree => "tree",
166            ObjectKind::Blob => "blob",
167        }
168    }
169}
170
171/// A loose Git object: its type and its uncompressed body.
172#[derive(Clone, Debug)]
173pub struct GitObject {
174    pub kind: ObjectKind,
175    pub body: Vec<u8>,
176}
177
178impl GitObject {
179    /// The object's SHA-1: `sha1("<type> <len>\0" + body)`.
180    pub fn id(&self) -> [u8; 20] {
181        let mut hasher = Sha1::new();
182        hasher.update(format!("{} {}\0", self.kind.header_name(), self.body.len()).as_bytes());
183        hasher.update(&self.body);
184        hasher.finalize()
185    }
186}
187
188/// One file in the repository snapshot.
189#[derive(Clone, Debug)]
190pub struct RepoFile {
191    pub path: String,
192    pub content: Vec<u8>,
193    pub executable: bool,
194}
195
196/// Author/committer identity and commit timestamp.
197#[derive(Clone, Debug)]
198pub struct CommitMeta {
199    pub message: String,
200    pub author_name: String,
201    pub author_email: String,
202    /// Seconds since the Unix epoch, written with a `+0000` offset.
203    ///
204    /// Defaulted by the caller to a *fixed* value rather than "now": the commit timestamp is
205    /// part of the commit object, so a wall-clock default would give the two HTTP requests of
206    /// a single clone two different commit SHAs.
207    pub timestamp: i64,
208}
209
210/// A repository snapshot compiled into Git objects.
211#[derive(Clone, Debug)]
212pub struct BuiltRepo {
213    pub branch: String,
214    pub commit_id: [u8; 20],
215    /// Every object needed to reconstruct the commit: the commit, all trees, all blobs.
216    pub objects: Vec<GitObject>,
217}
218
219impl BuiltRepo {
220    pub fn commit_hex(&self) -> String {
221        hex_id(&self.commit_id)
222    }
223}
224
225/// Render a 20-byte object ID as lowercase hex.
226pub fn hex_id(id: &[u8; 20]) -> String {
227    let mut s = String::with_capacity(40);
228    for b in id {
229        s.push_str(&format!("{:02x}", b));
230    }
231    s
232}
233
234/// Build the objects for a single-commit repository containing `files`.
235///
236/// Returns an error for a path Git itself would refuse, so a model that invents
237/// `../../etc/passwd` gets a clear failure instead of a pack no client will accept.
238pub fn build_repo(branch: &str, files: &[RepoFile], meta: &CommitMeta) -> Result<BuiltRepo> {
239    if branch.is_empty() || branch.contains(|c: char| c.is_whitespace() || c.is_control()) {
240        bail!("Invalid branch name {branch:?}: must be non-empty and contain no whitespace");
241    }
242
243    let mut objects: Vec<GitObject> = Vec::new();
244    let mut root = TreeNode::default();
245
246    for file in files {
247        let components = validate_path(&file.path)?;
248        let blob = GitObject {
249            kind: ObjectKind::Blob,
250            body: file.content.clone(),
251        };
252        let blob_id = blob.id();
253        objects.push(blob);
254
255        let mode = if file.executable { "100755" } else { "100644" };
256        root.insert(&components, blob_id, mode)?;
257    }
258
259    let tree_id = root.write(&mut objects);
260
261    let commit_body = build_commit(&tree_id, meta);
262    let commit = GitObject {
263        kind: ObjectKind::Commit,
264        body: commit_body,
265    };
266    let commit_id = commit.id();
267    objects.push(commit);
268
269    Ok(BuiltRepo {
270        branch: branch.to_string(),
271        commit_id,
272        objects,
273    })
274}
275
276/// Split and validate a repository-relative path.
277fn validate_path(path: &str) -> Result<Vec<String>> {
278    if path.is_empty() {
279        bail!("File path is empty");
280    }
281    if path.starts_with('/') {
282        bail!("File path {path:?} is absolute; paths are relative to the repository root");
283    }
284    if path.contains('\0') {
285        bail!("File path {path:?} contains a NUL byte");
286    }
287
288    let components: Vec<String> = path.split('/').map(|s| s.to_string()).collect();
289    for component in &components {
290        match component.as_str() {
291            "" => {
292                bail!("File path {path:?} has an empty component (leading, trailing or double '/')")
293            }
294            "." | ".." => bail!("File path {path:?} contains a '.' or '..' component"),
295            ".git" => bail!(
296                "File path {path:?} contains a '.git' component, which Git refuses to check out"
297            ),
298            _ => {}
299        }
300    }
301    Ok(components)
302}
303
304/// In-memory tree under construction.
305#[derive(Default)]
306struct TreeNode {
307    /// name -> (mode, blob id)
308    files: BTreeMap<String, (&'static str, [u8; 20])>,
309    /// name -> subtree
310    dirs: BTreeMap<String, TreeNode>,
311}
312
313impl TreeNode {
314    fn insert(
315        &mut self,
316        components: &[String],
317        blob_id: [u8; 20],
318        mode: &'static str,
319    ) -> Result<()> {
320        let (name, rest) = components
321            .split_first()
322            .expect("validate_path guarantees at least one component");
323
324        if rest.is_empty() {
325            if self.dirs.contains_key(name) {
326                bail!("Path collision: {name:?} is used both as a directory and as a file");
327            }
328            self.files.insert(name.clone(), (mode, blob_id));
329        } else {
330            if self.files.contains_key(name) {
331                bail!("Path collision: {name:?} is used both as a file and as a directory");
332            }
333            self.dirs
334                .entry(name.clone())
335                .or_default()
336                .insert(rest, blob_id, mode)?;
337        }
338        Ok(())
339    }
340
341    /// Serialise this tree (and its subtrees) into `objects`, returning this tree's ID.
342    fn write(&self, objects: &mut Vec<GitObject>) -> [u8; 20] {
343        // Git sorts tree entries by name, with directory names compared as if they ended in
344        // '/'. Getting this wrong produces a tree whose SHA no other Git implementation
345        // reproduces, so it is done explicitly rather than relying on BTreeMap order.
346        let mut entries: Vec<(String, &'static str, [u8; 20])> = Vec::new();
347
348        for (name, (mode, id)) in &self.files {
349            entries.push((name.clone(), mode, *id));
350        }
351        for (name, node) in &self.dirs {
352            let id = node.write(objects);
353            entries.push((format!("{name}/"), "40000", id));
354        }
355        entries.sort_by(|a, b| a.0.cmp(&b.0));
356
357        let mut body = Vec::new();
358        for (sort_name, mode, id) in entries {
359            let name = sort_name.strip_suffix('/').unwrap_or(&sort_name);
360            body.extend_from_slice(mode.as_bytes());
361            body.push(b' ');
362            body.extend_from_slice(name.as_bytes());
363            body.push(0);
364            body.extend_from_slice(&id);
365        }
366
367        let tree = GitObject {
368            kind: ObjectKind::Tree,
369            body,
370        };
371        let id = tree.id();
372        objects.push(tree);
373        id
374    }
375}
376
377fn build_commit(tree_id: &[u8; 20], meta: &CommitMeta) -> Vec<u8> {
378    // Identities may not contain '<', '>' or newlines; sanitise rather than emit a commit no
379    // Git client can parse.
380    let name = sanitize_identity(&meta.author_name, "NetGet");
381    let email = sanitize_identity(&meta.author_email, "netget@localhost");
382    let when = format!("{} +0000", meta.timestamp);
383
384    let mut message = meta.message.replace('\r', "");
385    if message.is_empty() {
386        message.push_str("Initial commit");
387    }
388    if !message.ends_with('\n') {
389        message.push('\n');
390    }
391
392    let mut body = String::new();
393    body.push_str(&format!("tree {}\n", hex_id(tree_id)));
394    body.push_str(&format!("author {name} <{email}> {when}\n"));
395    body.push_str(&format!("committer {name} <{email}> {when}\n"));
396    body.push('\n');
397    body.push_str(&message);
398    body.into_bytes()
399}
400
401fn sanitize_identity(value: &str, fallback: &str) -> String {
402    // `<` and `>` delimit the email in `author Name <email> <when>`, so they are removed rather
403    // than substituted: a space where one stood would still shift the header's fields. Every
404    // control character becomes a space, which is `utils::sanitize`'s line-field rule — the
405    // header is one line, and deleting a newline would join two names into one.
406    let cleaned: String = crate::utils::sanitize::line_field(value)
407        .chars()
408        .filter(|c| !matches!(c, '<' | '>'))
409        .collect();
410    let cleaned = cleaned.trim().to_string();
411    if cleaned.is_empty() {
412        fallback.to_string()
413    } else {
414        cleaned
415    }
416}
417
418/// Serialise objects into a version-2 pack file.
419///
420/// Layout: `"PACK"`, version 2, object count, then for each object a type/size header
421/// followed by its zlib-compressed body, then a SHA-1 trailer over everything preceding it.
422pub fn write_pack(objects: &[GitObject]) -> Vec<u8> {
423    let mut pack = Vec::new();
424    pack.extend_from_slice(b"PACK");
425    pack.extend_from_slice(&2u32.to_be_bytes());
426    pack.extend_from_slice(&(objects.len() as u32).to_be_bytes());
427
428    for object in objects {
429        write_object_header(&mut pack, object.kind, object.body.len());
430        pack.extend_from_slice(&zlib_stored(&object.body));
431    }
432
433    let checksum = sha1(&pack);
434    pack.extend_from_slice(&checksum);
435    pack
436}
437
438/// Pack object header: 3 type bits and a variable-length size, 7 bits per continuation byte.
439fn write_object_header(out: &mut Vec<u8>, kind: ObjectKind, size: usize) {
440    let mut byte = ((kind as u8) << 4) | ((size & 0x0f) as u8);
441    let mut remaining = size >> 4;
442    while remaining > 0 {
443        out.push(byte | 0x80);
444        byte = (remaining & 0x7f) as u8;
445        remaining >>= 7;
446    }
447    out.push(byte);
448}
449
450/// Wrap `data` in a zlib stream using only uncompressed ("stored") deflate blocks.
451///
452/// Git requires object bodies to be zlib streams but does not require them to be *compressed*;
453/// a stored block is a valid deflate block, so this produces a stream every zlib decoder
454/// accepts. Doing it by hand keeps the Git server free of a compression dependency, which
455/// matters because `flate2` is optional in this workspace and is not enabled by the `git`
456/// feature. The cost is that packs are slightly larger than the input.
457fn zlib_stored(data: &[u8]) -> Vec<u8> {
458    let mut out = Vec::with_capacity(data.len() + 16);
459    // CMF = 0x78 (deflate, 32K window), FLG = 0x01 -> 0x7801 is divisible by 31, no preset dict.
460    out.push(0x78);
461    out.push(0x01);
462
463    if data.is_empty() {
464        out.push(0x01); // BFINAL=1, BTYPE=00 (stored)
465        out.extend_from_slice(&0u16.to_le_bytes());
466        out.extend_from_slice(&(!0u16).to_le_bytes());
467    } else {
468        let mut chunks = data.chunks(u16::MAX as usize).peekable();
469        while let Some(chunk) = chunks.next() {
470            let final_block = chunks.peek().is_none();
471            out.push(if final_block { 0x01 } else { 0x00 });
472            let len = chunk.len() as u16;
473            out.extend_from_slice(&len.to_le_bytes());
474            out.extend_from_slice(&(!len).to_le_bytes());
475            out.extend_from_slice(chunk);
476        }
477    }
478
479    out.extend_from_slice(&adler32(data).to_be_bytes());
480    out
481}
482
483fn adler32(data: &[u8]) -> u32 {
484    const MOD: u32 = 65521;
485    let mut a: u32 = 1;
486    let mut b: u32 = 0;
487    for &byte in data {
488        a = (a + byte as u32) % MOD;
489        b = (b + a) % MOD;
490    }
491    (b << 16) | a
492}