Set repository canonical refs

We set the `HEAD` and eg. `refs/heads/master` refs when updating
repositories, which allows users to easily clone with git.

For now, `HEAD` is chosen based on a unanimity quorum of delegates.
This can be made more sophisticated in the future.

We've also had to ignore some refs during verification, since these
have cropped in for eg. due to the staging copy clone and the
changes included here.

Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
Alexis Sellier 2022-10-19 10:27:24 +02:00
parent 10d8519101
commit 3bff9a9dc1
No known key found for this signature in database
7 changed files with 134 additions and 37 deletions

View File

@ -8,7 +8,7 @@ use thiserror::Error;
use radicle::crypto::{PublicKey, Signer};
use radicle::node::Handle;
use radicle::ssh;
use radicle::storage::{ReadRepository, WriteStorage};
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
/// The service invoked by git on the remote repository, during a push.
const GIT_RECEIVE_PACK: &str = "git-receive-pack";
@ -180,6 +180,7 @@ pub fn run(profile: radicle::Profile) -> Result<(), Box<dyn std::error::Error +
if child.wait()?.success() {
if *service == GIT_RECEIVE_PACK {
profile.storage.sign_refs(&proj, &profile.signer)?;
proj.set_head()?;
// Connect to local node and announce refs to the network.
// If our node is not running, we simply skip this step, as the
// refs will be announced eventually, when the node restarts.

View File

@ -118,24 +118,47 @@ pub fn remote_refs(url: &Url) -> Result<HashMap<RemoteId, Refs>, ListRefsError>
let refs = remote.list()?;
for r in refs {
let (id, refname) = parse_ref::<PublicKey>(r.name())?;
let entry = remotes.entry(id).or_insert_with(Refs::default);
entry.insert(refname.into(), r.oid().into());
// Skip the `HEAD` reference, as it is untrusted.
if r.name() == "HEAD" {
continue;
}
// Nb. skip refs that don't have a public key namespace.
if let (Some(id), refname) = parse_ref::<PublicKey>(r.name())? {
let entry = remotes.entry(id).or_insert_with(Refs::default);
entry.insert(refname.into(), r.oid().into());
}
}
Ok(remotes)
}
/// Parse a ref string.
pub fn parse_ref<T>(s: &str) -> Result<(T, format::Qualified), RefError>
/// Parse a ref string. Returns an error if it isn't namespaced.
pub fn parse_ref_namespaced<T>(s: &str) -> Result<(T, format::Qualified), RefError>
where
T: FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
{
match parse_ref::<T>(s) {
Ok((None, refname)) => Err(RefError::MissingNamespace(refname.to_ref_string())),
Ok((Some(t), r)) => Ok((t, r)),
Err(err) => Err(err),
}
}
/// Parse a ref string. Optionally returns a namespace.
pub fn parse_ref<T>(s: &str) -> Result<(Option<T>, format::Qualified), RefError>
where
T: FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
{
let input = format::RefStr::try_from_str(s)?;
match input.to_namespaced() {
None => Err(RefError::MissingNamespace(input.to_owned())),
None => {
let refname = Qualified::from_refstr(input)
.ok_or_else(|| RefError::InvalidName(input.to_owned()))?;
Ok((None, refname))
}
Some(ns) => {
let id = ns
.namespace()
@ -146,7 +169,8 @@ where
err: Box::new(err),
})?;
let rest = ns.strip_namespace();
Ok((id, rest))
Ok((Some(id), rest))
}
}
}

View File

@ -509,7 +509,7 @@ mod test {
let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap();
let err = Doc::<Unverified>::head(&remote, &repo).unwrap_err();
assert!(dbg!(err).is_not_found());
assert!(err.is_not_found());
let err = Doc::load_at(oid.into(), &repo).unwrap_err();
assert!(err.is_not_found());

View File

@ -3,7 +3,6 @@ use std::io;
use std::path::Path;
use std::str::FromStr;
use git_ref_format::{lit, Qualified};
use once_cell::sync::Lazy;
use thiserror::Error;
@ -23,6 +22,8 @@ pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad")
pub enum InitError {
#[error("doc: {0}")]
Doc(#[from] identity::project::DocError),
#[error("project: {0}")]
Project(#[from] storage::git::ProjectError),
#[error("doc: {0}")]
DocVerification(#[from] identity::project::VerificationError),
#[error("git: {0}")]
@ -76,6 +77,7 @@ pub fn init<G: Signer, S: storage::WriteStorage>(
git::configure_remote(repo, &REMOTE_NAME, &url)?;
git::push(repo, &REMOTE_NAME, pk, [(&default_branch, &default_branch)])?;
let signed = storage.sign_refs(&project, signer)?;
let _head = project.set_head()?;
Ok((id, signed))
}
@ -158,27 +160,15 @@ pub fn fork<G: Signer, S: storage::WriteStorage>(
let me = signer.public_key();
let repository = storage.repository(proj)?;
let (canonical_id, project) = repository.project_identity()?;
let (canonical_head, _) = repository.head()?;
let raw = repository.raw();
// TODO: Test the fork functions in isolation.
// TODO: Move to function on `Repository`.
let canonical_head = {
let mut heads = Vec::new();
for delegate in project.delegates.iter() {
let refname = Qualified::from(lit::refs_heads(&project.default_branch));
let r = repository.reference_oid(&delegate.id, &refname)?.into();
heads.push(r);
}
match heads.as_slice() {
[head] => Ok(*head),
// FIXME: This branch is not tested.
heads => raw.merge_base_many(heads),
}
}?;
// TODO: We should only get the project HEAD once we've stored the canonical identity
// branch on disk. This way it can use what we stored, instead of recomputing it.
raw.reference(
&git::refs::storage::branch(me, &project.default_branch),
canonical_head,
*canonical_head,
false,
&format!("creating default branch for {me}"),
)?;

View File

@ -59,6 +59,9 @@ pub enum FetchError {
Verify(#[from] git::VerifyError),
#[error(transparent)]
Storage(#[from] Error),
// TODO: This should wrap a more specific error.
#[error("repository head: {0}")]
SetHead(#[from] ProjectError),
}
pub type RemoteId = PublicKey;
@ -229,6 +232,7 @@ pub trait WriteStorage: ReadStorage {
type Repository: WriteRepository;
fn repository(&self, proj: Id) -> Result<Self::Repository, Error>;
// TODO: Move this to `WriteRepository`.
fn sign_refs<G: Signer>(
&self,
repository: &Self::Repository,
@ -246,6 +250,11 @@ pub trait ReadRepository {
fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git_ext::Error>;
/// Get the canonical head of this repository.
///
/// Returns the [`Oid`] as well as the qualified reference name.
fn head(&self) -> Result<(Oid, Qualified), ProjectError>;
/// Get the `reference` for the given `remote`.
///
/// Returns `None` is the reference did not exist.
@ -276,6 +285,7 @@ pub trait ReadRepository {
pub trait WriteRepository: ReadRepository {
fn fetch(&mut self, url: &Url) -> Result<Vec<RefUpdate>, FetchError>;
fn set_head(&self) -> Result<Oid, ProjectError>;
fn raw(&self) -> &git2::Repository;
}

View File

@ -28,6 +28,7 @@ pub static REMOTES_GLOB: Lazy<refspec::PatternString> =
pub static SIGNATURES_GLOB: Lazy<refspec::PatternString> =
Lazy::new(|| refspec::pattern!("refs/namespaces/*/radicle/signature"));
// TODO: Is this is the wrong place for this type?
#[derive(Error, Debug)]
pub enum ProjectError {
#[error("identity branches diverge from each other")]
@ -228,12 +229,9 @@ impl Repository {
Ok(Self { id, backend })
}
pub fn head(&self) -> Result<git2::Commit, git2::Error> {
// TODO: Find longest history, get document and get head.
// Perhaps we should even set a local `HEAD` or at least `refs/heads/master`
todo!();
}
/// Verify all references in the repository, checking that they are signed
/// as part of 'sigrefs'. Also verify that no signed reference is missing
/// from the repository.
pub fn verify(&self) -> Result<(), VerifyError> {
let mut remotes: HashMap<RemoteId, Refs> = self
.remotes()?
@ -243,15 +241,32 @@ impl Repository {
})
.collect::<Result<_, VerifyError>>()?;
// TODO: We could create a higher level `references` method that skips
// canonical/unverifiable refs.
for r in self.backend.references()? {
let r = r?;
let name = r.name().ok_or(VerifyError::InvalidRef)?;
let oid = r.target().ok_or(VerifyError::InvalidRef)?;
let oid = if let Some(oid) = r.target() {
oid
} else {
// Ignore symbolic refs, eg. `HEAD`.
continue;
};
let (remote_id, refname) = git::parse_ref::<RemoteId>(name)?;
let remote_id = if let Some(remote_id) = remote_id {
remote_id
} else {
// Ignore refs that aren't part of a namespace, eg. canonical refs.
continue;
};
if refname == *refs::SIGNATURE_REF {
// Ignore the signed-refs reference, as this is what we're verifying.
continue;
}
let remote = remotes
.get_mut(&remote_id)
.ok_or(VerifyError::InvalidRemote(remote_id))?;
@ -358,7 +373,7 @@ impl Repository {
|reference| -> Result<RemoteId, refs::Error> {
let r = reference?;
let name = r.name().ok_or(refs::Error::InvalidRef)?;
let (id, _) = git::parse_ref::<RemoteId>(name)?;
let (id, _) = git::parse_ref_namespaced::<RemoteId>(name)?;
Ok(id)
},
@ -376,7 +391,7 @@ impl Repository {
|reference| -> Result<_, _> {
let r = reference?;
let name = r.name().ok_or(refs::Error::InvalidRef)?;
let (id, _) = git::parse_ref::<RemoteId>(name)?;
let (id, _) = git::parse_ref_namespaced::<RemoteId>(name)?;
let remote = self.remote(&id)?;
Ok((id, remote))
@ -440,6 +455,7 @@ impl ReadRepository for Repository {
remote: &RemoteId,
reference: &git::Qualified,
) -> Result<Oid, git::Error> {
// TODO: Use native git2 function for this.
let oid = self
.reference(remote, reference)?
.target()
@ -486,6 +502,31 @@ impl ReadRepository for Repository {
fn project_identity(&self) -> Result<(Oid, identity::Doc<Unverified>), ProjectError> {
Repository::project(self)
}
fn head(&self) -> Result<(Oid, Qualified), ProjectError> {
// TODO: We shouldn't need to re-construct the history here; we should use the cached
// document head of the "trusted" (local) user for this project.
// In the `fork` function for example, we call Repository::project_identity again,
// This should only be necessary once.
let (_, project) = self.project_identity()?;
let branch_ref = Qualified::from(lit::refs_heads(&project.default_branch));
let raw = self.raw();
let mut heads = Vec::new();
for delegate in project.delegates.iter() {
let r = self.reference_oid(&delegate.id, &branch_ref)?.into();
heads.push(r);
}
let oid = match heads.as_slice() {
[head] => Ok(*head),
// FIXME: This branch is not tested.
heads => raw.merge_base_many(heads),
}?;
Ok((oid.into(), branch_ref))
}
}
impl WriteRepository for Repository {
@ -598,10 +639,27 @@ impl WriteRepository for Repository {
// Fetch from the staging copy into the canonical repo.
remote.fetch(refs, Some(&mut opts), None)?;
}
// Set repository HEAD for git cloning support.
self.set_head()?;
Ok(updates)
}
fn set_head(&self) -> Result<Oid, ProjectError> {
let head_ref = refname!("HEAD");
let (head, branch_ref) = self.head()?;
log::debug!("Setting ref {:?} -> {:?}", &branch_ref, head);
self.raw()
.reference(&branch_ref, *head, true, "set-local-branch (radicle)")?;
log::debug!("Setting ref {:?} -> {:?}", head_ref, branch_ref);
self.raw()
.reference_symbolic(&head_ref, &branch_ref, true, "set-head (radicle)")?;
Ok(head)
}
fn raw(&self) -> &git2::Repository {
&self.backend
}
@ -788,6 +846,7 @@ mod tests {
.id();
git::push(&proj_repo, "rad", alice_id, [(&refname, &refname)]).unwrap();
alice.sign_refs(&alice_proj_storage, &alice_signer).unwrap();
alice_proj_storage.set_head().unwrap();
// Have Bob fetch Alice's new commit.
let updates = bob.repository(proj_id).unwrap().fetch(&alice_url).unwrap();
@ -887,7 +946,11 @@ mod tests {
target
.remote_anonymous(&format!("rad://{}", proj))
.unwrap()
.fetch(&["refs/*:refs/*"], Some(&mut opts), None)
.fetch(
&["refs/namespaces/*:refs/namespaces/*"],
Some(&mut opts),
None,
)
.unwrap();
stream.shutdown(net::Shutdown::Both).unwrap();

View File

@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use git_ref_format as fmt;
use git_url::Url;
use radicle_git_ext as git_ext;
@ -85,6 +86,10 @@ impl ReadRepository for MockRepository {
Ok(true)
}
fn head(&self) -> Result<(Oid, fmt::Qualified), ProjectError> {
todo!()
}
fn path(&self) -> &std::path::Path {
todo!()
}
@ -152,4 +157,8 @@ impl WriteRepository for MockRepository {
fn raw(&self) -> &git2::Repository {
todo!()
}
fn set_head(&self) -> Result<Oid, ProjectError> {
todo!()
}
}