use anyhow::{bail, Result};
use std::collections::BTreeMap;
#[derive(Clone)]
struct Sha1 {
state: [u32; 5],
buffer: [u8; 64],
buffered: usize,
length_bits: u64,
}
impl Sha1 {
fn new() -> Self {
Self {
state: [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0],
buffer: [0u8; 64],
buffered: 0,
length_bits: 0,
}
}
fn update(&mut self, mut data: &[u8]) {
self.length_bits = self.length_bits.wrapping_add((data.len() as u64) * 8);
if self.buffered > 0 {
let take = std::cmp::min(64 - self.buffered, data.len());
self.buffer[self.buffered..self.buffered + take].copy_from_slice(&data[..take]);
self.buffered += take;
data = &data[take..];
if self.buffered == 64 {
let block = self.buffer;
self.compress(&block);
self.buffered = 0;
}
}
let mut chunks = data.chunks_exact(64);
for chunk in &mut chunks {
let mut block = [0u8; 64];
block.copy_from_slice(chunk);
self.compress(&block);
}
let rest = chunks.remainder();
if !rest.is_empty() {
self.buffer[..rest.len()].copy_from_slice(rest);
self.buffered = rest.len();
}
}
fn finalize(mut self) -> [u8; 20] {
let length_bits = self.length_bits;
self.update_raw(&[0x80]);
while self.buffered != 56 {
self.update_raw(&[0x00]);
}
self.update_raw(&length_bits.to_be_bytes());
let mut out = [0u8; 20];
for (i, word) in self.state.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
out
}
fn update_raw(&mut self, data: &[u8]) {
for &byte in data {
self.buffer[self.buffered] = byte;
self.buffered += 1;
if self.buffered == 64 {
let block = self.buffer;
self.compress(&block);
self.buffered = 0;
}
}
}
fn compress(&mut self, block: &[u8; 64]) {
let mut w = [0u32; 80];
for (i, word) in w.iter_mut().take(16).enumerate() {
*word = u32::from_be_bytes([
block[i * 4],
block[i * 4 + 1],
block[i * 4 + 2],
block[i * 4 + 3],
]);
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let [mut a, mut b, mut c, mut d, mut e] = self.state;
for (i, &word) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | ((!b) & d), 0x5A827999u32),
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
_ => (b ^ c ^ d, 0xCA62C1D6),
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(word);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp;
}
self.state[0] = self.state[0].wrapping_add(a);
self.state[1] = self.state[1].wrapping_add(b);
self.state[2] = self.state[2].wrapping_add(c);
self.state[3] = self.state[3].wrapping_add(d);
self.state[4] = self.state[4].wrapping_add(e);
}
}
pub fn sha1(data: &[u8]) -> [u8; 20] {
let mut hasher = Sha1::new();
hasher.update(data);
hasher.finalize()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ObjectKind {
Commit = 1,
Tree = 2,
Blob = 3,
}
impl ObjectKind {
fn header_name(self) -> &'static str {
match self {
ObjectKind::Commit => "commit",
ObjectKind::Tree => "tree",
ObjectKind::Blob => "blob",
}
}
}
#[derive(Clone, Debug)]
pub struct GitObject {
pub kind: ObjectKind,
pub body: Vec<u8>,
}
impl GitObject {
pub fn id(&self) -> [u8; 20] {
let mut hasher = Sha1::new();
hasher.update(format!("{} {}\0", self.kind.header_name(), self.body.len()).as_bytes());
hasher.update(&self.body);
hasher.finalize()
}
}
#[derive(Clone, Debug)]
pub struct RepoFile {
pub path: String,
pub content: Vec<u8>,
pub executable: bool,
}
#[derive(Clone, Debug)]
pub struct CommitMeta {
pub message: String,
pub author_name: String,
pub author_email: String,
pub timestamp: i64,
}
#[derive(Clone, Debug)]
pub struct BuiltRepo {
pub branch: String,
pub commit_id: [u8; 20],
pub objects: Vec<GitObject>,
}
impl BuiltRepo {
pub fn commit_hex(&self) -> String {
hex_id(&self.commit_id)
}
}
pub fn hex_id(id: &[u8; 20]) -> String {
let mut s = String::with_capacity(40);
for b in id {
s.push_str(&format!("{:02x}", b));
}
s
}
pub fn build_repo(branch: &str, files: &[RepoFile], meta: &CommitMeta) -> Result<BuiltRepo> {
if branch.is_empty() || branch.contains(|c: char| c.is_whitespace() || c.is_control()) {
bail!("Invalid branch name {branch:?}: must be non-empty and contain no whitespace");
}
let mut objects: Vec<GitObject> = Vec::new();
let mut root = TreeNode::default();
for file in files {
let components = validate_path(&file.path)?;
let blob = GitObject {
kind: ObjectKind::Blob,
body: file.content.clone(),
};
let blob_id = blob.id();
objects.push(blob);
let mode = if file.executable { "100755" } else { "100644" };
root.insert(&components, blob_id, mode)?;
}
let tree_id = root.write(&mut objects);
let commit_body = build_commit(&tree_id, meta);
let commit = GitObject {
kind: ObjectKind::Commit,
body: commit_body,
};
let commit_id = commit.id();
objects.push(commit);
Ok(BuiltRepo {
branch: branch.to_string(),
commit_id,
objects,
})
}
fn validate_path(path: &str) -> Result<Vec<String>> {
if path.is_empty() {
bail!("File path is empty");
}
if path.starts_with('/') {
bail!("File path {path:?} is absolute; paths are relative to the repository root");
}
if path.contains('\0') {
bail!("File path {path:?} contains a NUL byte");
}
let components: Vec<String> = path.split('/').map(|s| s.to_string()).collect();
for component in &components {
match component.as_str() {
"" => {
bail!("File path {path:?} has an empty component (leading, trailing or double '/')")
}
"." | ".." => bail!("File path {path:?} contains a '.' or '..' component"),
".git" => bail!(
"File path {path:?} contains a '.git' component, which Git refuses to check out"
),
_ => {}
}
}
Ok(components)
}
#[derive(Default)]
struct TreeNode {
files: BTreeMap<String, (&'static str, [u8; 20])>,
dirs: BTreeMap<String, TreeNode>,
}
impl TreeNode {
fn insert(
&mut self,
components: &[String],
blob_id: [u8; 20],
mode: &'static str,
) -> Result<()> {
let (name, rest) = components
.split_first()
.expect("validate_path guarantees at least one component");
if rest.is_empty() {
if self.dirs.contains_key(name) {
bail!("Path collision: {name:?} is used both as a directory and as a file");
}
self.files.insert(name.clone(), (mode, blob_id));
} else {
if self.files.contains_key(name) {
bail!("Path collision: {name:?} is used both as a file and as a directory");
}
self.dirs
.entry(name.clone())
.or_default()
.insert(rest, blob_id, mode)?;
}
Ok(())
}
fn write(&self, objects: &mut Vec<GitObject>) -> [u8; 20] {
let mut entries: Vec<(String, &'static str, [u8; 20])> = Vec::new();
for (name, (mode, id)) in &self.files {
entries.push((name.clone(), mode, *id));
}
for (name, node) in &self.dirs {
let id = node.write(objects);
entries.push((format!("{name}/"), "40000", id));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut body = Vec::new();
for (sort_name, mode, id) in entries {
let name = sort_name.strip_suffix('/').unwrap_or(&sort_name);
body.extend_from_slice(mode.as_bytes());
body.push(b' ');
body.extend_from_slice(name.as_bytes());
body.push(0);
body.extend_from_slice(&id);
}
let tree = GitObject {
kind: ObjectKind::Tree,
body,
};
let id = tree.id();
objects.push(tree);
id
}
}
fn build_commit(tree_id: &[u8; 20], meta: &CommitMeta) -> Vec<u8> {
let name = sanitize_identity(&meta.author_name, "NetGet");
let email = sanitize_identity(&meta.author_email, "netget@localhost");
let when = format!("{} +0000", meta.timestamp);
let mut message = meta.message.replace('\r', "");
if message.is_empty() {
message.push_str("Initial commit");
}
if !message.ends_with('\n') {
message.push('\n');
}
let mut body = String::new();
body.push_str(&format!("tree {}\n", hex_id(tree_id)));
body.push_str(&format!("author {name} <{email}> {when}\n"));
body.push_str(&format!("committer {name} <{email}> {when}\n"));
body.push('\n');
body.push_str(&message);
body.into_bytes()
}
fn sanitize_identity(value: &str, fallback: &str) -> String {
let cleaned: String = crate::utils::sanitize::line_field(value)
.chars()
.filter(|c| !matches!(c, '<' | '>'))
.collect();
let cleaned = cleaned.trim().to_string();
if cleaned.is_empty() {
fallback.to_string()
} else {
cleaned
}
}
pub fn write_pack(objects: &[GitObject]) -> Vec<u8> {
let mut pack = Vec::new();
pack.extend_from_slice(b"PACK");
pack.extend_from_slice(&2u32.to_be_bytes());
pack.extend_from_slice(&(objects.len() as u32).to_be_bytes());
for object in objects {
write_object_header(&mut pack, object.kind, object.body.len());
pack.extend_from_slice(&zlib_stored(&object.body));
}
let checksum = sha1(&pack);
pack.extend_from_slice(&checksum);
pack
}
fn write_object_header(out: &mut Vec<u8>, kind: ObjectKind, size: usize) {
let mut byte = ((kind as u8) << 4) | ((size & 0x0f) as u8);
let mut remaining = size >> 4;
while remaining > 0 {
out.push(byte | 0x80);
byte = (remaining & 0x7f) as u8;
remaining >>= 7;
}
out.push(byte);
}
fn zlib_stored(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() + 16);
out.push(0x78);
out.push(0x01);
if data.is_empty() {
out.push(0x01); out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&(!0u16).to_le_bytes());
} else {
let mut chunks = data.chunks(u16::MAX as usize).peekable();
while let Some(chunk) = chunks.next() {
let final_block = chunks.peek().is_none();
out.push(if final_block { 0x01 } else { 0x00 });
let len = chunk.len() as u16;
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&(!len).to_le_bytes());
out.extend_from_slice(chunk);
}
}
out.extend_from_slice(&adler32(data).to_be_bytes());
out
}
fn adler32(data: &[u8]) -> u32 {
const MOD: u32 = 65521;
let mut a: u32 = 1;
let mut b: u32 = 0;
for &byte in data {
a = (a + byte as u32) % MOD;
b = (b + a) % MOD;
}
(b << 16) | a
}