michal/tit
Browse tree · Show commit · Download archive
Blob: src/git/repository.rs
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use gix::hash::{Kind, ObjectId};
use gix::objs::{Data, Kind as ObjectKind, tree::EntryKind};
use gix_pack::data::Version;
use gix_pack::data::output::{Count, Entry, bytes::FromEntriesIter};
use thiserror::Error;
const MAX_OBJECTS_PER_PACK: usize = 100_000;
const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024;
const MAX_PACK_BYTES: usize = 256 * 1024 * 1024;
pub(crate) struct GitRepository {
repository: gix::Repository,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GitReference {
pub(crate) name: Vec<u8>,
pub(crate) target: ObjectId,
pub(crate) peeled: Option<ObjectId>,
pub(crate) symbolic_target: Option<Vec<u8>>,
}
impl GitRepository {
pub(crate) fn open(path: &Path) -> Result<Self, GitRepositoryError> {
let repository = gix::open(path).map_err(|error| GitRepositoryError::Open {
path: path.to_owned(),
reason: error.to_string(),
})?;
if !repository.is_bare() {
return Err(GitRepositoryError::NotBare(path.to_owned()));
}
Ok(Self { repository })
}
pub(crate) fn object_format(&self) -> Kind {
self.repository.object_hash()
}
pub(crate) fn create_bare(path: &Path, object_format: Kind) -> Result<(), GitRepositoryError> {
let options = gix::create::Options {
object_hash: (object_format == Kind::Sha256).then_some(Kind::Sha256),
..Default::default()
};
let repository = gix::ThreadSafeRepository::init(path, gix::create::Kind::Bare, options)
.map_err(|error| GitRepositoryError::Create {
path: path.to_owned(),
reason: error.to_string(),
})?;
drop(repository);
fs::write(path.join("HEAD"), b"ref: refs/heads/main\n").map_err(|source| {
GitRepositoryError::Filesystem {
path: path.join("HEAD"),
source,
}
})?;
let created = Self::open(path)?;
if created.object_format() != object_format {
return Err(GitRepositoryError::WrongObjectFormat);
}
Ok(())
}
pub(crate) fn copy_bare(source: &Path, destination: &Path) -> Result<Kind, GitRepositoryError> {
let source_repository = Self::open(source)?;
let object_format = source_repository.object_format();
copy_repository_tree(source, destination)?;
let copy = Self::open(destination)?;
if copy.object_format() != object_format {
return Err(GitRepositoryError::WrongObjectFormat);
}
Ok(object_format)
}
pub(crate) fn references(&self) -> Result<Vec<GitReference>, GitRepositoryError> {
let mut references = Vec::new();
let platform = self
.repository
.references()
.map_err(|error| GitRepositoryError::References(error.to_string()))?;
let iterator = platform
.all()
.map_err(|error| GitRepositoryError::References(error.to_string()))?;
for reference in iterator {
let reference =
reference.map_err(|error| GitRepositoryError::References(error.to_string()))?;
let Some(target) = reference.try_id().map(gix::Id::detach) else {
continue;
};
let name = reference.name().as_bstr().to_vec();
let peeled = if name.starts_with(b"refs/tags/") {
let mut candidate = reference.clone();
let candidate = candidate
.peel_to_id()
.map_err(|error| GitRepositoryError::References(error.to_string()))?
.detach();
(candidate != target).then_some(candidate)
} else {
None
};
references.push(GitReference {
name,
target,
peeled,
symbolic_target: None,
});
}
references.sort_by(|left, right| left.name.cmp(&right.name));
if let Some(head) = self
.repository
.head_ref()
.map_err(|error| GitRepositoryError::References(error.to_string()))?
{
let target = head.id().detach();
references.insert(
0,
GitReference {
name: b"HEAD".to_vec(),
target,
peeled: None,
symbolic_target: Some(head.name().as_bstr().to_vec()),
},
);
}
Ok(references)
}
pub(crate) fn make_pack(
&self,
wants: &[ObjectId],
haves: &[ObjectId],
) -> Result<Vec<u8>, GitRepositoryError> {
if wants.iter().any(|want| want.kind() != self.object_format())
|| haves.iter().any(|have| have.kind() != self.object_format())
{
return Err(GitRepositoryError::WrongObjectFormat);
}
let advertised: HashSet<_> = self
.references()?
.into_iter()
.flat_map(|reference| [Some(reference.target), reference.peeled])
.flatten()
.collect();
if wants.is_empty() || wants.iter().any(|want| !advertised.contains(want)) {
return Err(GitRepositoryError::UnadvertisedWant);
}
let excluded = self.walk_reachable(haves, true)?;
let mut objects: Vec<_> = self
.walk_reachable(wants, false)?
.into_iter()
.filter(|id| !excluded.contains(id))
.collect();
objects.sort();
let object_count =
u32::try_from(objects.len()).map_err(|_| GitRepositoryError::ObjectLimit)?;
let mut entries = Vec::with_capacity(objects.len());
let mut total_object_bytes = 0_usize;
for id in objects {
let object = self.find_object(id)?;
total_object_bytes = total_object_bytes
.checked_add(object.data.len())
.ok_or(GitRepositoryError::ObjectLimit)?;
if object.data.len() > MAX_OBJECT_BYTES || total_object_bytes > MAX_PACK_BYTES {
return Err(GitRepositoryError::ObjectLimit);
}
let count = Count::from_data(id, None);
let data = Data::new(&object.data, object.kind, self.object_format());
entries.push(
Entry::from_data(&count, &data)
.map_err(|error| GitRepositoryError::Pack(error.to_string()))?,
);
}
let chunks = entries
.into_iter()
.map(|entry| Ok::<_, std::io::Error>(vec![entry]));
let mut writer = FromEntriesIter::new(
chunks,
Vec::new(),
object_count,
Version::V2,
self.object_format(),
);
for result in writer.by_ref() {
result.map_err(|error| GitRepositoryError::Pack(error.to_string()))?;
}
let pack = writer.into_write();
if pack.len() > MAX_PACK_BYTES {
return Err(GitRepositoryError::PackLimit);
}
Ok(pack)
}
fn walk_reachable(
&self,
roots: &[ObjectId],
permit_missing_roots: bool,
) -> Result<HashSet<ObjectId>, GitRepositoryError> {
let mut seen = HashSet::new();
let mut pending = roots.to_vec();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
if seen.len() > MAX_OBJECTS_PER_PACK {
return Err(GitRepositoryError::ObjectLimit);
}
let object = match self.repository.try_find_object(id) {
Ok(Some(object)) => object,
Ok(None) if permit_missing_roots && roots.contains(&id) => {
seen.remove(&id);
continue;
}
Ok(None) => return Err(GitRepositoryError::MissingObject(id)),
Err(error) => {
return Err(GitRepositoryError::Object {
id,
reason: error.to_string(),
});
}
};
match object.kind {
ObjectKind::Blob => {}
ObjectKind::Commit => {
let commit = object.try_to_commit_ref().map_err(|error| {
GitRepositoryError::DamagedObject {
id,
reason: error.to_string(),
}
})?;
pending.push(self.parse_id(commit.tree, id)?);
for parent in commit.parents {
pending.push(self.parse_id(parent, id)?);
}
}
ObjectKind::Tree => {
let tree = object.into_tree();
for entry in tree.iter() {
let entry = entry.map_err(|error| GitRepositoryError::DamagedObject {
id,
reason: error.to_string(),
})?;
if entry.kind() != EntryKind::Commit {
pending.push(entry.oid().to_owned());
}
}
}
ObjectKind::Tag => {
let tag = object.try_to_tag_ref().map_err(|error| {
GitRepositoryError::DamagedObject {
id,
reason: error.to_string(),
}
})?;
pending.push(self.parse_id(tag.target, id)?);
}
}
}
Ok(seen)
}
fn find_object(&self, id: ObjectId) -> Result<gix::Object<'_>, GitRepositoryError> {
self.repository
.find_object(id)
.map_err(|error| GitRepositoryError::Object {
id,
reason: error.to_string(),
})
}
fn parse_id(&self, input: &[u8], owner: ObjectId) -> Result<ObjectId, GitRepositoryError> {
let id = ObjectId::from_hex(input).map_err(|error| GitRepositoryError::DamagedObject {
id: owner,
reason: error.to_string(),
})?;
if id.kind() != self.object_format() {
return Err(GitRepositoryError::DamagedObject {
id: owner,
reason: "object ID uses the wrong hash format".to_owned(),
});
}
Ok(id)
}
}
#[derive(Debug, Error)]
pub(crate) enum GitRepositoryError {
#[error("cannot create Git repository {path}: {reason}")]
Create { path: PathBuf, reason: String },
#[error("cannot open Git repository {path}: {reason}")]
Open { path: PathBuf, reason: String },
#[error("Git repository is not bare: {0}")]
NotBare(PathBuf),
#[error("cannot read Git references: {0}")]
References(String),
#[error("cannot read Git object {id}: {reason}")]
Object { id: ObjectId, reason: String },
#[error("Git object does not exist: {0}")]
MissingObject(ObjectId),
#[error("Git object {id} is damaged: {reason}")]
DamagedObject { id: ObjectId, reason: String },
#[error("client requested an object that was not advertised")]
UnadvertisedWant,
#[error("object ID uses the wrong repository hash format")]
WrongObjectFormat,
#[error("Git repository contains a symbolic link or special file: {0}")]
UnsafeFile(PathBuf),
#[error("cannot access Git repository path {path}: {source}")]
Filesystem {
path: PathBuf,
source: std::io::Error,
},
#[error("Git object count or decoded size exceeds the limit")]
ObjectLimit,
#[error("generated Git pack exceeds the limit")]
PackLimit,
#[error("cannot generate Git pack: {0}")]
Pack(String),
}
fn copy_repository_tree(source: &Path, destination: &Path) -> Result<(), GitRepositoryError> {
fs::create_dir(destination).map_err(|source_error| GitRepositoryError::Filesystem {
path: destination.to_owned(),
source: source_error,
})?;
for entry in fs::read_dir(source).map_err(|source_error| GitRepositoryError::Filesystem {
path: source.to_owned(),
source: source_error,
})? {
let entry = entry.map_err(|source_error| GitRepositoryError::Filesystem {
path: source.to_owned(),
source: source_error,
})?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
let file_type =
entry
.file_type()
.map_err(|source_error| GitRepositoryError::Filesystem {
path: source_path.clone(),
source: source_error,
})?;
if file_type.is_dir() {
copy_repository_tree(&source_path, &destination_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &destination_path).map_err(|source_error| {
GitRepositoryError::Filesystem {
path: source_path,
source: source_error,
}
})?;
} else {
return Err(GitRepositoryError::UnsafeFile(source_path));
}
}
Ok(())
}