summaryrefslogtreecommitdiff
path: root/src/git
diff options
context:
space:
mode:
authorKim Altintop <kim@eagain.io>2023-01-09 13:18:33 +0100
committerKim Altintop <kim@eagain.io>2023-01-09 13:18:33 +0100
commitd2f423521ec76406944ad83098ec33afe20c692b (patch)
treeafd86bcb088eebdd61ba4e52fa666ff0f41c42a2 /src/git
This is it
Squashed commit of all the exploration history. Development starts here. Signed-off-by: Kim Altintop <kim@eagain.io>
Diffstat (limited to 'src/git')
-rw-r--r--src/git/commit.rs46
-rw-r--r--src/git/config.rs31
-rw-r--r--src/git/refs.rs327
-rw-r--r--src/git/repo.rs93
-rw-r--r--src/git/serde.rs61
5 files changed, 558 insertions, 0 deletions
diff --git a/src/git/commit.rs b/src/git/commit.rs
new file mode 100644
index 0000000..cb4a516
--- /dev/null
+++ b/src/git/commit.rs
@@ -0,0 +1,46 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::ssh;
+
+const SSHSIG_NAMESPACE: &str = "git";
+
+pub fn commit_signed<'a, S>(
+ signer: &mut S,
+ repo: &'a git2::Repository,
+ msg: impl AsRef<str>,
+ tree: &git2::Tree<'a>,
+ parents: &[&git2::Commit<'a>],
+) -> crate::Result<git2::Oid>
+where
+ S: crate::keys::Signer + ?Sized,
+{
+ let aut = repo.signature()?;
+ let buf = repo.commit_create_buffer(&aut, &aut, msg.as_ref(), tree, parents)?;
+ let sig = {
+ let hash = ssh::HashAlg::Sha512;
+ let data = ssh::SshSig::signed_data(SSHSIG_NAMESPACE, hash, &buf)?;
+ let sig = signer.sign(&data)?;
+ ssh::SshSig::new(signer.ident().key_data(), SSHSIG_NAMESPACE, hash, sig)?
+ .to_pem(ssh::LineEnding::LF)?
+ };
+ let oid = repo.commit_signed(
+ buf.as_str().expect("commit buffer to be utf8"),
+ sig.as_str(),
+ None,
+ )?;
+
+ Ok(oid)
+}
+
+pub fn verify_commit_signature(
+ repo: &git2::Repository,
+ oid: &git2::Oid,
+) -> crate::Result<ssh::PublicKey> {
+ let (sig, data) = repo.extract_signature(oid, None)?;
+ let sig = ssh::SshSig::from_pem(&*sig)?;
+ let pk = ssh::PublicKey::from(sig.public_key().clone());
+ pk.verify(SSHSIG_NAMESPACE, &data, &sig)?;
+
+ Ok(pk)
+}
diff --git a/src/git/config.rs b/src/git/config.rs
new file mode 100644
index 0000000..bc8dfcc
--- /dev/null
+++ b/src/git/config.rs
@@ -0,0 +1,31 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::ops::Deref;
+
+/// A read-only snapshot of a [`git2::Config`]
+pub struct Snapshot(git2::Config);
+
+impl Deref for Snapshot {
+ type Target = git2::Config;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl TryFrom<git2::Config> for Snapshot {
+ type Error = git2::Error;
+
+ fn try_from(mut cfg: git2::Config) -> Result<Self, Self::Error> {
+ cfg.snapshot().map(Self)
+ }
+}
+
+impl TryFrom<&mut git2::Config> for Snapshot {
+ type Error = git2::Error;
+
+ fn try_from(cfg: &mut git2::Config) -> Result<Self, Self::Error> {
+ cfg.snapshot().map(Self)
+ }
+}
diff --git a/src/git/refs.rs b/src/git/refs.rs
new file mode 100644
index 0000000..5960434
--- /dev/null
+++ b/src/git/refs.rs
@@ -0,0 +1,327 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::{
+ fmt,
+ ops::Deref,
+ str::FromStr,
+};
+use std::{
+ borrow::Cow,
+ cell::Cell,
+ collections::HashMap,
+ path::Path,
+ rc::Rc,
+};
+
+pub const MAX_FILENAME: usize = 255;
+
+#[derive(Clone, Copy)]
+pub struct Options {
+ pub allow_onelevel: bool,
+ pub allow_pattern: bool,
+}
+
+pub mod error {
+ use thiserror::Error;
+
+ #[derive(Debug, Error)]
+ pub enum RefFormat {
+ #[error("empty input")]
+ Empty,
+ #[error("name too long")]
+ NameTooLong,
+ #[error("invalid character {0:?}")]
+ InvalidChar(char),
+ #[error("invalid character sequence {0:?}")]
+ InvalidSeq(&'static str),
+ #[error("must contain at least one '/'")]
+ OneLevel,
+ #[error("must contain at most one '*'")]
+ Pattern,
+ }
+}
+
+pub fn check_ref_format(opts: Options, s: &str) -> Result<(), error::RefFormat> {
+ use error::RefFormat::*;
+
+ match s {
+ "" => Err(Empty),
+ "@" => Err(InvalidChar('@')),
+ "." => Err(InvalidChar('.')),
+ _ => {
+ let mut globs = 0;
+ let mut parts = 0;
+
+ for x in s.split('/') {
+ if x.is_empty() {
+ return Err(InvalidSeq("//"));
+ }
+ if x.len() > MAX_FILENAME {
+ return Err(NameTooLong);
+ }
+
+ parts += 1;
+
+ if x.ends_with(".lock") {
+ return Err(InvalidSeq(".lock"));
+ }
+
+ let last_char = x.len() - 1;
+ for (i, y) in x.chars().zip(x.chars().cycle().skip(1)).enumerate() {
+ match y {
+ ('.', '.') => return Err(InvalidSeq("..")),
+ ('@', '{') => return Err(InvalidSeq("@{")),
+ ('*', _) => globs += 1,
+ (z, _) => match z {
+ '\0' | '\\' | '~' | '^' | ':' | '?' | '[' | ' ' => {
+ return Err(InvalidChar(z))
+ },
+ '.' if i == 0 || i == last_char => return Err(InvalidChar('.')),
+ _ if z.is_ascii_control() => return Err(InvalidChar(z)),
+
+ _ => continue,
+ },
+ }
+ }
+ }
+
+ if parts < 2 && !opts.allow_onelevel {
+ Err(OneLevel)
+ } else if globs > 1 && opts.allow_pattern {
+ Err(Pattern)
+ } else if globs > 0 && !opts.allow_pattern {
+ Err(InvalidChar('*'))
+ } else {
+ Ok(())
+ }
+ },
+ }
+}
+
+/// A valid git refname.
+///
+/// If the input starts with 'refs/`, it is taken verbatim (after validation),
+/// otherwise `refs/heads/' is prepended (ie. the input is considered a branch
+/// name).
+#[derive(
+ Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, ::serde::Serialize, ::serde::Deserialize,
+)]
+#[serde(try_from = "String")]
+pub struct Refname(String);
+
+impl Refname {
+ pub fn main() -> Self {
+ Self("refs/heads/main".into())
+ }
+
+ pub fn master() -> Self {
+ Self("refs/heads/master".into())
+ }
+}
+
+impl fmt::Display for Refname {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self)
+ }
+}
+
+impl Deref for Refname {
+ type Target = str;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl AsRef<str> for Refname {
+ fn as_ref(&self) -> &str {
+ self
+ }
+}
+
+impl AsRef<Path> for Refname {
+ fn as_ref(&self) -> &Path {
+ Path::new(self.0.as_str())
+ }
+}
+
+impl From<Refname> for String {
+ fn from(r: Refname) -> Self {
+ r.0
+ }
+}
+
+impl FromStr for Refname {
+ type Err = error::RefFormat;
+
+ fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
+ Self::try_from(s.to_owned())
+ }
+}
+
+impl TryFrom<String> for Refname {
+ type Error = error::RefFormat;
+
+ fn try_from(value: String) -> core::result::Result<Self, Self::Error> {
+ const OPTIONS: Options = Options {
+ allow_onelevel: true,
+ allow_pattern: false,
+ };
+
+ check_ref_format(OPTIONS, &value)?;
+ let name = if value.starts_with("refs/") {
+ value
+ } else {
+ format!("refs/heads/{}", value)
+ };
+
+ Ok(Self(name))
+ }
+}
+
+/// Iterator over reference names
+///
+/// [`git2::ReferenceNames`] is advertised as more efficient if only the
+/// reference names are needed, and not a full [`git2::Reference`]. However,
+/// that type has overly restrictive lifetime constraints (because,
+/// inexplicably, it does **not** consume [`git2::References`] even though
+/// the documentation claims so).
+///
+/// We can work around this by transforming the reference `&str` into some other
+/// type which is not subject to its lifetime.
+#[must_use = "iterators are lazy and do nothing unless consumed"]
+pub struct ReferenceNames<'a, F> {
+ inner: git2::References<'a>,
+ trans: F,
+}
+
+impl<'a, F> ReferenceNames<'a, F> {
+ pub fn new(refs: git2::References<'a>, trans: F) -> Self {
+ Self { inner: refs, trans }
+ }
+}
+
+impl<'a, F, E, T> Iterator for ReferenceNames<'a, F>
+where
+ F: FnMut(&str) -> core::result::Result<T, E>,
+ E: From<git2::Error>,
+{
+ type Item = core::result::Result<T, E>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ self.inner
+ .names()
+ .next()
+ .map(|r| r.map_err(E::from).and_then(|name| (self.trans)(name)))
+ }
+}
+
+pub struct Transaction<'a> {
+ tx: git2::Transaction<'a>,
+ locked: HashMap<Refname, Rc<Cell<Op>>>,
+}
+
+impl<'a> Transaction<'a> {
+ pub fn new(repo: &'a git2::Repository) -> super::Result<Self> {
+ let tx = repo.transaction()?;
+ Ok(Self {
+ tx,
+ locked: HashMap::new(),
+ })
+ }
+
+ pub fn lock_ref(&mut self, name: Refname) -> super::Result<LockedRef> {
+ use std::collections::hash_map::Entry;
+
+ let lref = match self.locked.entry(name) {
+ Entry::Vacant(v) => {
+ let name = v.key().clone();
+ self.tx.lock_ref(&name)?;
+ let op = Rc::new(Cell::new(Op::default()));
+ v.insert(Rc::clone(&op));
+ LockedRef { name, op }
+ },
+ Entry::Occupied(v) => LockedRef {
+ name: v.key().clone(),
+ op: Rc::clone(v.get()),
+ },
+ };
+
+ Ok(lref)
+ }
+
+ pub fn commit(mut self) -> super::Result<()> {
+ for (name, op) in self.locked {
+ match op.take() {
+ Op::None => continue,
+ Op::DirTarget { target, reflog } => {
+ self.tx.set_target(&name, target, None, &reflog)?
+ },
+ Op::SymTarget { target, reflog } => {
+ self.tx.set_symbolic_target(&name, &target, None, &reflog)?
+ },
+ Op::Remove => self.tx.remove(&name)?,
+ }
+ }
+ self.tx.commit()
+ }
+}
+
+#[derive(Debug, Default)]
+enum Op {
+ #[default]
+ None,
+ DirTarget {
+ target: git2::Oid,
+ reflog: Cow<'static, str>,
+ },
+ SymTarget {
+ target: Refname,
+ reflog: Cow<'static, str>,
+ },
+ #[allow(unused)]
+ Remove,
+}
+
+pub struct LockedRef {
+ name: Refname,
+ op: Rc<Cell<Op>>,
+}
+
+impl LockedRef {
+ pub fn name(&self) -> &Refname {
+ &self.name
+ }
+
+ pub fn set_target<S: Into<Cow<'static, str>>>(&self, target: git2::Oid, reflog: S) {
+ self.op.set(Op::DirTarget {
+ target,
+ reflog: reflog.into(),
+ })
+ }
+
+ pub fn set_symbolic_target<S: Into<Cow<'static, str>>>(&self, target: Refname, reflog: S) {
+ self.op.set(Op::SymTarget {
+ target,
+ reflog: reflog.into(),
+ })
+ }
+
+ #[allow(unused)]
+ pub fn remove(&self) {
+ self.op.set(Op::Remove)
+ }
+}
+
+impl fmt::Display for LockedRef {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.name())
+ }
+}
+
+impl From<LockedRef> for Refname {
+ fn from(LockedRef { name, .. }: LockedRef) -> Self {
+ name
+ }
+}
diff --git a/src/git/repo.rs b/src/git/repo.rs
new file mode 100644
index 0000000..3fb8a16
--- /dev/null
+++ b/src/git/repo.rs
@@ -0,0 +1,93 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::HashSet,
+ ffi::OsString,
+ io::{
+ BufReader,
+ Seek,
+ Write,
+ },
+ iter,
+ path::Path,
+ result::Result as StdResult,
+};
+
+use super::{
+ if_not_found_then,
+ Result,
+};
+use crate::{
+ fs::LockedFile,
+ io::Lines,
+};
+
+pub fn open<P: AsRef<Path>>(path: P) -> Result<git2::Repository> {
+ git2::Repository::open_ext(
+ path,
+ git2::RepositoryOpenFlags::FROM_ENV,
+ iter::empty::<OsString>(),
+ )
+}
+
+pub fn open_bare<P: AsRef<Path>>(path: P) -> Result<git2::Repository> {
+ git2::Repository::open_ext(
+ path,
+ git2::RepositoryOpenFlags::FROM_ENV | git2::RepositoryOpenFlags::BARE,
+ iter::empty::<OsString>(),
+ )
+}
+
+pub fn open_or_init<P: AsRef<Path>>(path: P, opts: InitOpts) -> Result<git2::Repository> {
+ if_not_found_then(open(path.as_ref()), || init(path, opts))
+}
+
+pub struct InitOpts<'a> {
+ pub bare: bool,
+ pub description: &'a str,
+ pub initial_head: &'a str,
+}
+
+pub fn init<P: AsRef<Path>>(path: P, opts: InitOpts) -> Result<git2::Repository> {
+ git2::Repository::init_opts(
+ path,
+ git2::RepositoryInitOptions::new()
+ .no_reinit(true)
+ .mkdir(true)
+ .mkpath(true)
+ .bare(opts.bare)
+ .description(opts.description)
+ .initial_head(opts.initial_head),
+ )
+}
+
+pub fn add_alternates<'a, I>(repo: &git2::Repository, alt: I) -> crate::Result<()>
+where
+ I: IntoIterator<Item = &'a git2::Repository>,
+{
+ let (mut persistent, known) = {
+ let mut lock = LockedFile::atomic(
+ repo.path().join("objects").join("info").join("alternates"),
+ false,
+ LockedFile::DEFAULT_PERMISSIONS,
+ )?;
+ lock.seek(std::io::SeekFrom::Start(0))?;
+ let mut bufread = BufReader::new(lock);
+ let known = Lines::new(&mut bufread).collect::<StdResult<HashSet<String>, _>>()?;
+ (bufread.into_inner(), known)
+ };
+ {
+ let odb = repo.odb()?;
+ for alternate in alt {
+ let path = format!("{}", alternate.path().join("objects").display());
+ odb.add_disk_alternate(&path)?;
+ if !known.contains(&path) {
+ writeln!(&mut persistent, "{}", path)?
+ }
+ }
+ }
+ persistent.persist()?;
+
+ Ok(())
+}
diff --git a/src/git/serde.rs b/src/git/serde.rs
new file mode 100644
index 0000000..e20df47
--- /dev/null
+++ b/src/git/serde.rs
@@ -0,0 +1,61 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::str::FromStr;
+
+use serde::{
+ Deserialize,
+ Deserializer,
+ Serialize,
+ Serializer,
+};
+
+pub mod oid {
+ use super::*;
+
+ #[derive(serde::Serialize, serde::Deserialize)]
+ pub struct Oid(#[serde(with = "self")] pub git2::Oid);
+
+ impl From<git2::Oid> for Oid {
+ fn from(oid: git2::Oid) -> Self {
+ Self(oid)
+ }
+ }
+
+ pub fn serialize<S>(oid: &git2::Oid, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(&oid.to_string())
+ }
+
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<git2::Oid, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let hex: &str = Deserialize::deserialize(deserializer)?;
+ git2::Oid::from_str(hex).map_err(serde::de::Error::custom)
+ }
+
+ pub mod option {
+ use super::*;
+
+ pub fn serialize<S>(oid: &Option<git2::Oid>, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ oid.as_ref().map(ToString::to_string).serialize(serializer)
+ }
+
+ #[allow(unused)]
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<git2::Oid>, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let hex: Option<&str> = Deserialize::deserialize(deserializer)?;
+ hex.map(FromStr::from_str)
+ .transpose()
+ .map_err(serde::de::Error::custom)
+ }
+ }
+}