michal/tit
Browse tree · Show commit · Download archive
Blob: src/git/repository.rs
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use gix::hash::{Kind, ObjectId};
use gix::objs::{Commit, Data, Kind as ObjectKind, tree::EntryKind};
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix::refs::{FullName, Target};
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 default_branch(&self) -> Result<Option<String>, GitRepositoryError> {
let name = self
.repository
.head_name()
.map_err(|error| GitRepositoryError::References(error.to_string()))?;
Ok(name
.filter(|name| name.as_bstr().starts_with(b"refs/heads/"))
.and_then(|name| std::str::from_utf8(name.as_bstr()).ok().map(str::to_owned)))
}
pub(crate) fn set_default_branch(&self, name: &str) -> Result<(), GitRepositoryError> {
self.resolve_branch(name)?;
let target = FullName::try_from(gix::bstr::BString::from(name.as_bytes()))
.map_err(|_| GitRepositoryError::InvalidBranch)?;
self.repository
.edit_reference(RefEdit {
change: Change::Update {
log: Default::default(),
expected: PreviousValue::Any,
new: Target::Symbolic(target),
},
name: "HEAD".try_into().expect("HEAD is a valid reference name"),
deref: false,
})
.map_err(|error| GitRepositoryError::References(error.to_string()))?;
Ok(())
}
pub(crate) fn resolve_branch(&self, name: &str) -> Result<ObjectId, GitRepositoryError> {
if !name.starts_with("refs/heads/") || name.len() > 1024 {
return Err(GitRepositoryError::InvalidBranch);
}
let reference = self
.repository
.try_find_reference(name)
.map_err(|error| GitRepositoryError::References(error.to_string()))?
.ok_or_else(|| GitRepositoryError::MissingReference(name.to_owned()))?;
let target = reference
.try_id()
.map(gix::Id::detach)
.ok_or_else(|| GitRepositoryError::MissingReference(name.to_owned()))?;
if self.find_object(target)?.kind != ObjectKind::Commit {
return Err(GitRepositoryError::BranchNotCommit);
}
Ok(target)
}
pub(crate) fn reference_target(
&self,
name: &str,
) -> Result<Option<ObjectId>, GitRepositoryError> {
self.repository
.try_find_reference(name)
.map(|reference| {
reference.and_then(|reference| reference.try_id().map(gix::Id::detach))
})
.map_err(|error| GitRepositoryError::References(error.to_string()))
}
pub(crate) fn update_reference(
&self,
name: &str,
expected: Option<ObjectId>,
new: ObjectId,
) -> Result<(), GitRepositoryError> {
self.update_reference_with_log(name, expected, new, "pull request revision")
}
pub(crate) fn update_reference_with_log(
&self,
name: &str,
expected: Option<ObjectId>,
new: ObjectId,
message: &str,
) -> Result<(), GitRepositoryError> {
if new.kind() != self.object_format() {
return Err(GitRepositoryError::WrongObjectFormat);
}
let name = FullName::try_from(name)
.map_err(|_| GitRepositoryError::InvalidReference(name.to_owned()))?;
let edit = RefEdit {
name,
deref: false,
change: Change::Update {
expected: expected.map_or(PreviousValue::MustNotExist, |id| {
PreviousValue::MustExistAndMatch(Target::Object(id))
}),
new: Target::Object(new),
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: message.into(),
},
},
};
self.repository
.edit_references_as([edit], None)
.map_err(|error| GitRepositoryError::RefTransaction(error.to_string()))?;
Ok(())
}
pub(crate) fn prepare_merge_commit(
&self,
base: ObjectId,
head: ObjectId,
actor: &str,
created_at: i64,
message: &str,
) -> Result<ObjectId, GitRepositoryError> {
let repository = gix::open(self.repository.path())
.map_err(|error| GitRepositoryError::Merge(error.to_string()))?
.with_object_memory();
write_merge_commit(&repository, base, head, actor, created_at, message)
}
pub(crate) fn write_merge_commit(
&self,
base: ObjectId,
head: ObjectId,
actor: &str,
created_at: i64,
message: &str,
) -> Result<ObjectId, GitRepositoryError> {
write_merge_commit(&self.repository, base, head, actor, created_at, message)
}
pub(crate) fn make_pack(
&self,
wants: &[ObjectId],
haves: &[ObjectId],
) -> Result<Vec<u8>, GitRepositoryError> {
let mut output = Vec::new();
self.write_pack(wants, haves, &mut output, &AtomicBool::new(false))?;
Ok(output)
}
pub(crate) fn write_pack(
&self,
wants: &[ObjectId],
haves: &[ObjectId],
output: impl Write,
cancelled: &AtomicBool,
) -> Result<(), 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 permitted_missing_roots = haves.iter().copied().collect::<HashSet<_>>();
let excluded = self.walk_reachable(haves, Some(&permitted_missing_roots), cancelled)?;
let mut objects: Vec<_> = self
.walk_reachable(wants, None, cancelled)?
.into_iter()
.filter(|id| !excluded.contains(id))
.collect();
objects.sort();
let object_count =
u32::try_from(objects.len()).map_err(|_| GitRepositoryError::ObjectLimit)?;
let mut total_object_bytes = 0_usize;
let chunks = objects.into_iter().map(|id| {
if cancelled.load(Ordering::Relaxed) {
return Err(std::io::Error::other("pack generation was cancelled"));
}
self.pack_entry(id, &mut total_object_bytes)
.map(|entry| vec![entry])
.map_err(std::io::Error::other)
});
let mut writer = FromEntriesIter::new(
chunks,
CountingWriter::new(output, MAX_PACK_BYTES),
object_count,
Version::V2,
self.object_format(),
);
for result in writer.by_ref() {
result.map_err(|error| GitRepositoryError::Pack(error.to_string()))?;
}
writer
.into_write()
.finish()
.map_err(|error| GitRepositoryError::Pack(error.to_string()))
}
fn pack_entry(
&self,
id: ObjectId,
total_object_bytes: &mut usize,
) -> Result<Entry, GitRepositoryError> {
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());
Entry::from_data(&count, &data).map_err(|error| GitRepositoryError::Pack(error.to_string()))
}
pub(crate) fn integrity_check(&self) -> Result<(), GitRepositoryError> {
let roots: Vec<_> = self
.references()?
.into_iter()
.flat_map(|reference| [Some(reference.target), reference.peeled])
.flatten()
.collect();
self.walk_reachable(&roots, None, &AtomicBool::new(false))?;
Ok(())
}
fn walk_reachable(
&self,
roots: &[ObjectId],
permitted_missing_roots: Option<&HashSet<ObjectId>>,
cancelled: &AtomicBool,
) -> Result<HashSet<ObjectId>, GitRepositoryError> {
let mut seen = HashSet::new();
let mut pending = roots.to_vec();
while let Some(id) = pending.pop() {
if cancelled.load(Ordering::Relaxed) {
return Err(GitRepositoryError::Cancelled);
}
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 permitted_missing_roots
.is_some_and(|missing_roots| missing_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)
}
}
struct CountingWriter<W> {
inner: W,
written: usize,
limit: usize,
}
impl<W> CountingWriter<W> {
fn new(inner: W, limit: usize) -> Self {
Self {
inner,
written: 0,
limit,
}
}
}
impl<W: Write> CountingWriter<W> {
fn finish(mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
impl<W: Write> Write for CountingWriter<W> {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
let total = self
.written
.checked_add(data.len())
.ok_or_else(|| std::io::Error::other("pack byte limit exceeded"))?;
if total > self.limit {
return Err(std::io::Error::other("pack byte limit exceeded"));
}
self.inner.write_all(data)?;
self.written = total;
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
fn write_merge_commit(
repository: &gix::Repository,
base: ObjectId,
head: ObjectId,
actor: &str,
created_at: i64,
message: &str,
) -> Result<ObjectId, GitRepositoryError> {
if base.kind() != repository.object_hash() || head.kind() != repository.object_hash() {
return Err(GitRepositoryError::WrongObjectFormat);
}
let options = repository
.tree_merge_options()
.map_err(|error| GitRepositoryError::Merge(error.to_string()))?
.with_rewrites(Some(Default::default()))
.with_fail_on_conflict(Some(Default::default()));
let mut outcome = repository
.merge_commits(base, head, Default::default(), options.into())
.map_err(|error| GitRepositoryError::Merge(error.to_string()))?;
if outcome
.tree_merge
.has_unresolved_conflicts(Default::default())
{
return Err(GitRepositoryError::MergeConflict);
}
let tree = outcome
.tree_merge
.tree
.write()
.map_err(|error| GitRepositoryError::Merge(error.to_string()))?
.detach();
let signature = gix::actor::Signature {
name: actor.into(),
email: format!("{actor}@users.tit").into(),
time: gix::date::Time {
seconds: created_at,
offset: 0,
},
};
let commit = Commit {
message: message.into(),
tree,
author: signature.clone(),
committer: signature,
encoding: None,
parents: [base, head].into_iter().collect(),
extra_headers: Default::default(),
};
repository
.write_object(&commit)
.map(gix::Id::detach)
.map_err(|error| GitRepositoryError::Merge(error.to_string()))
}
#[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("Git branch name is not valid")]
InvalidBranch,
#[error("Git reference name is not valid: {0}")]
InvalidReference(String),
#[error("Git reference does not exist: {0}")]
MissingReference(String),
#[error("Git branch does not point to a commit")]
BranchNotCommit,
#[error("cannot update Git references: {0}")]
RefTransaction(String),
#[error("cannot create a Git merge commit: {0}")]
Merge(String),
#[error("Git merge has conflicts")]
MergeConflict,
#[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("Git pack generation was cancelled")]
Cancelled,
#[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(())
}