1use anyhow::{bail, Result};
18use std::collections::BTreeMap;
19
20#[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 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 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
146pub fn sha1(data: &[u8]) -> [u8; 20] {
148 let mut hasher = Sha1::new();
149 hasher.update(data);
150 hasher.finalize()
151}
152
153#[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#[derive(Clone, Debug)]
173pub struct GitObject {
174 pub kind: ObjectKind,
175 pub body: Vec<u8>,
176}
177
178impl GitObject {
179 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#[derive(Clone, Debug)]
190pub struct RepoFile {
191 pub path: String,
192 pub content: Vec<u8>,
193 pub executable: bool,
194}
195
196#[derive(Clone, Debug)]
198pub struct CommitMeta {
199 pub message: String,
200 pub author_name: String,
201 pub author_email: String,
202 pub timestamp: i64,
208}
209
210#[derive(Clone, Debug)]
212pub struct BuiltRepo {
213 pub branch: String,
214 pub commit_id: [u8; 20],
215 pub objects: Vec<GitObject>,
217}
218
219impl BuiltRepo {
220 pub fn commit_hex(&self) -> String {
221 hex_id(&self.commit_id)
222 }
223}
224
225pub 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
234pub 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
276fn 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#[derive(Default)]
306struct TreeNode {
307 files: BTreeMap<String, (&'static str, [u8; 20])>,
309 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 fn write(&self, objects: &mut Vec<GitObject>) -> [u8; 20] {
343 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 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 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
418pub 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
438fn 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
450fn zlib_stored(data: &[u8]) -> Vec<u8> {
458 let mut out = Vec::with_capacity(data.len() + 16);
459 out.push(0x78);
461 out.push(0x01);
462
463 if data.is_empty() {
464 out.push(0x01); 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}