diff options
Diffstat (limited to 'src/git')
-rw-r--r-- | src/git/commit.rs | 46 | ||||
-rw-r--r-- | src/git/config.rs | 31 | ||||
-rw-r--r-- | src/git/refs.rs | 327 | ||||
-rw-r--r-- | src/git/repo.rs | 93 | ||||
-rw-r--r-- | src/git/serde.rs | 61 |
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) + } + } +} |