1133 lines
35 KiB
Rust
1133 lines
35 KiB
Rust
#![warn(clippy::unwrap_used)]
|
|
pub mod cob;
|
|
pub mod transport;
|
|
|
|
pub mod temp;
|
|
pub use temp::TempRepository;
|
|
|
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
use std::ops::{Deref, DerefMut};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::LazyLock;
|
|
use std::{fs, io};
|
|
|
|
use crypto::Verified;
|
|
|
|
use crate::git::canonical::Quorum;
|
|
use crate::git::raw::ErrorExt as _;
|
|
use crate::identity::crefs::GetCanonicalRefs as _;
|
|
use crate::identity::doc::DocError;
|
|
use crate::identity::{CanonicalRefs, Doc, DocAt, RepoId};
|
|
use crate::identity::{Identity, Project};
|
|
use crate::node::device::Device;
|
|
use crate::node::SyncedAt;
|
|
use crate::storage::refs;
|
|
use crate::storage::refs::{Refs, SignedRefs, SignedRefsAt};
|
|
use crate::storage::{
|
|
ReadRepository, ReadStorage, Remote, Remotes, RepositoryInfo, SetHead, SignRepository,
|
|
WriteRepository, WriteStorage,
|
|
};
|
|
use crate::{git, git::Oid, node};
|
|
|
|
use crate::git::fmt::{
|
|
refname, refspec, refspec::PatternStr, refspec::PatternString, Qualified, RefString,
|
|
};
|
|
use crate::git::RefError;
|
|
use crate::git::UserInfo;
|
|
pub use crate::storage::{Error, RepositoryError};
|
|
|
|
use super::refs::RefsAt;
|
|
use super::{RemoteId, RemoteRepository, ValidateRepository};
|
|
|
|
pub static NAMESPACES_GLOB: LazyLock<PatternString> =
|
|
LazyLock::new(|| git::fmt::pattern!("refs/namespaces/*"));
|
|
pub static SIGREFS_GLOB: LazyLock<refspec::PatternString> =
|
|
LazyLock::new(|| git::fmt::pattern!("refs/namespaces/*/rad/sigrefs"));
|
|
pub static CANONICAL_IDENTITY: LazyLock<git::fmt::Qualified> = LazyLock::new(|| {
|
|
git::fmt::Qualified::from_components(
|
|
git::fmt::component!("rad"),
|
|
git::fmt::component!("id"),
|
|
None,
|
|
)
|
|
});
|
|
|
|
/// A parsed Git reference.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Ref {
|
|
pub oid: crate::git::Oid,
|
|
pub name: RefString,
|
|
pub namespace: Option<RemoteId>,
|
|
}
|
|
|
|
impl TryFrom<git::raw::Reference<'_>> for Ref {
|
|
type Error = RefError;
|
|
|
|
fn try_from(r: git::raw::Reference) -> Result<Self, Self::Error> {
|
|
let name = r.name().ok_or(RefError::InvalidName)?;
|
|
let (namespace, name) = match git::parse_ref_namespaced::<RemoteId>(name) {
|
|
Ok((namespace, refname)) => (Some(namespace), refname.to_ref_string()),
|
|
Err(RefError::MissingNamespace(refname)) => (None, refname),
|
|
Err(err) => return Err(err),
|
|
};
|
|
let oid = r.resolve()?.target().ok_or(RefError::NoTarget)?;
|
|
|
|
Ok(Self {
|
|
namespace,
|
|
name,
|
|
oid: oid.into(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Storage {
|
|
path: PathBuf,
|
|
info: UserInfo,
|
|
}
|
|
|
|
impl ReadStorage for Storage {
|
|
type Repository = Repository;
|
|
|
|
fn info(&self) -> &UserInfo {
|
|
&self.info
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
self.path.as_path()
|
|
}
|
|
|
|
fn path_of(&self, rid: &RepoId) -> PathBuf {
|
|
paths::repository(&self, rid)
|
|
}
|
|
|
|
fn contains(&self, rid: &RepoId) -> Result<bool, RepositoryError> {
|
|
if paths::repository(&self, rid).exists() {
|
|
let _ = self.repository(*rid)?.head()?;
|
|
return Ok(true);
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
fn repository(&self, rid: RepoId) -> Result<Self::Repository, RepositoryError> {
|
|
Repository::open(paths::repository(self, &rid), rid)
|
|
}
|
|
|
|
fn repositories(&self) -> Result<Vec<RepositoryInfo>, Error> {
|
|
let mut repos = Vec::new();
|
|
|
|
for result in fs::read_dir(&self.path)? {
|
|
let path = result?;
|
|
|
|
// Skip non-directories.
|
|
if !path.file_type()?.is_dir() {
|
|
continue;
|
|
}
|
|
// Skip hidden files.
|
|
if path.file_name().to_string_lossy().starts_with('.') {
|
|
continue;
|
|
}
|
|
// Skip temporary repositories
|
|
if let Some(ext) = path.path().extension() {
|
|
if ext == TempRepository::EXT {
|
|
continue;
|
|
}
|
|
}
|
|
let rid = RepoId::try_from(path.file_name())
|
|
.map_err(|_| Error::InvalidId(path.file_name()))?;
|
|
|
|
let repo = match self.repository(rid) {
|
|
Ok(repo) => repo,
|
|
Err(e) => {
|
|
log::warn!(target: "storage", "Repository {rid} is invalid: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
let doc = match repo.identity_doc() {
|
|
Ok(doc) => doc.into(),
|
|
Err(e) => {
|
|
log::warn!(target: "storage", "Repository {rid} is invalid: looking up doc: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// For performance reasons, we don't do a full repository check here.
|
|
let head = match repo.head() {
|
|
Ok((_, head)) => head,
|
|
Err(e) => {
|
|
log::warn!(target: "storage", "Repository {rid} is invalid: looking up head: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
// Nb. This will be `None` if they were not found.
|
|
let refs = refs::SignedRefsAt::load(self.info.key, &repo)?;
|
|
let synced_at = refs
|
|
.as_ref()
|
|
.map(|r| node::SyncedAt::new(r.at, &repo))
|
|
.transpose()?;
|
|
|
|
repos.push(RepositoryInfo {
|
|
rid,
|
|
head,
|
|
doc,
|
|
refs,
|
|
synced_at,
|
|
});
|
|
}
|
|
Ok(repos)
|
|
}
|
|
}
|
|
|
|
impl WriteStorage for Storage {
|
|
type RepositoryMut = Repository;
|
|
|
|
fn repository_mut(&self, rid: RepoId) -> Result<Self::RepositoryMut, RepositoryError> {
|
|
Repository::open(paths::repository(self, &rid), rid)
|
|
}
|
|
|
|
fn create(&self, rid: RepoId) -> Result<Self::RepositoryMut, Error> {
|
|
Repository::create(paths::repository(self, &rid), rid, &self.info)
|
|
}
|
|
|
|
fn clean(&self, rid: RepoId) -> Result<Vec<RemoteId>, RepositoryError> {
|
|
let repo = self.repository(rid)?;
|
|
// N.b. we remove the repository if the `local` peer has no
|
|
// `rad/sigrefs`. There's no risk of them corrupting data.
|
|
let has_sigrefs = SignedRefsAt::load(self.info.key, &repo)?.is_some();
|
|
if has_sigrefs {
|
|
repo.clean(&self.info.key)
|
|
} else {
|
|
let remotes = repo.remote_ids()?.collect::<Result<_, _>>()?;
|
|
repo.remove()?;
|
|
Ok(remotes)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Storage {
|
|
/// Open a new storage instance and load its inventory.
|
|
pub fn open<P: AsRef<Path>>(path: P, info: UserInfo) -> Result<Self, Error> {
|
|
let path = path.as_ref().to_path_buf();
|
|
|
|
match fs::create_dir_all(&path) {
|
|
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
|
|
Err(err) => return Err(Error::Io(err)),
|
|
Ok(()) => {}
|
|
}
|
|
Ok(Self { path, info })
|
|
}
|
|
|
|
/// Create a [`Repository`] in a temporary directory.
|
|
///
|
|
/// This is used to prevent other processes accessing it during
|
|
/// initialization. Usually, callers will want to move the repository
|
|
/// to its destination after initialization in the temporary location.
|
|
pub fn temporary_repository(&self, rid: RepoId) -> Result<TempRepository, RepositoryError> {
|
|
if self.contains(&rid)? {
|
|
return Err(Error::Io(io::Error::new(
|
|
io::ErrorKind::AlreadyExists,
|
|
format!("refusing to create temporary repository for {rid}"),
|
|
))
|
|
.into());
|
|
}
|
|
TempRepository::new(self.path(), rid, &self.info)
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
self.path.as_path()
|
|
}
|
|
|
|
pub fn repositories_by_id<'a, I>(
|
|
&self,
|
|
rids: I,
|
|
) -> impl Iterator<Item = Result<RepositoryInfo, RepositoryError>> + use<'_, 'a, I>
|
|
where
|
|
I: Iterator<Item = &'a RepoId>,
|
|
{
|
|
rids.map(|rid| {
|
|
let repo = self.repository(*rid)?;
|
|
let (_, head) = repo.head()?;
|
|
let refs = refs::SignedRefsAt::load(self.info.key, &repo)?;
|
|
let synced_at = refs
|
|
.as_ref()
|
|
.map(|r| SyncedAt::new(r.at, &repo))
|
|
.transpose()?;
|
|
|
|
Ok(RepositoryInfo {
|
|
rid: *rid,
|
|
head,
|
|
doc: repo.identity_doc()?.into(),
|
|
refs,
|
|
synced_at,
|
|
})
|
|
})
|
|
}
|
|
|
|
pub fn inspect(&self) -> Result<(), RepositoryError> {
|
|
for r in self.repositories()? {
|
|
let rid = r.rid;
|
|
let repo = self.repository(rid)?;
|
|
|
|
for r in repo.raw().references()? {
|
|
let r = r?;
|
|
let name = r.name().ok_or(Error::InvalidRef)?;
|
|
let oid = r.resolve()?.target().ok_or(Error::InvalidRef)?;
|
|
|
|
println!("{} {oid} {name}", rid.urn());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Git implementation of [`WriteRepository`] using the `git2` crate.
|
|
pub struct Repository {
|
|
/// The repository identifier (RID).
|
|
pub id: RepoId,
|
|
/// The backing Git repository.
|
|
pub backend: git::raw::Repository,
|
|
}
|
|
|
|
impl AsRef<Repository> for Repository {
|
|
fn as_ref(&self) -> &Repository {
|
|
self
|
|
}
|
|
}
|
|
|
|
impl git::canonical::effects::Ancestry for Repository {
|
|
fn graph_ahead_behind(
|
|
&self,
|
|
commit: Oid,
|
|
upstream: Oid,
|
|
) -> Result<git::canonical::GraphAheadBehind, git::canonical::effects::GraphDescendant> {
|
|
git::canonical::effects::Ancestry::graph_ahead_behind(&self.backend, commit, upstream)
|
|
}
|
|
}
|
|
|
|
impl git::canonical::effects::FindMergeBase for Repository {
|
|
fn merge_base(
|
|
&self,
|
|
a: Oid,
|
|
b: Oid,
|
|
) -> Result<git::canonical::MergeBase, git::canonical::effects::MergeBaseError> {
|
|
git::canonical::effects::FindMergeBase::merge_base(&self.backend, a, b)
|
|
}
|
|
}
|
|
|
|
impl git::canonical::effects::FindObjects for Repository {
|
|
fn find_objects<'a, 'b, I>(
|
|
&self,
|
|
refname: &Qualified<'a>,
|
|
dids: I,
|
|
) -> Result<git::canonical::FoundObjects, git::canonical::effects::FindObjectsError>
|
|
where
|
|
I: Iterator<Item = &'b crate::prelude::Did>,
|
|
{
|
|
git::canonical::effects::FindObjects::find_objects(&self.backend, refname, dids)
|
|
}
|
|
}
|
|
|
|
/// A set of [`Validation`] errors that a caller **must use**.
|
|
#[must_use]
|
|
#[derive(Debug, Default)]
|
|
pub struct Validations(pub Vec<Validation>);
|
|
|
|
impl Validations {
|
|
pub fn append(&mut self, vs: &mut Self) {
|
|
self.0.append(&mut vs.0)
|
|
}
|
|
}
|
|
|
|
impl IntoIterator for Validations {
|
|
type Item = Validation;
|
|
type IntoIter = std::vec::IntoIter<Self::Item>;
|
|
|
|
fn into_iter(self) -> Self::IntoIter {
|
|
self.0.into_iter()
|
|
}
|
|
}
|
|
|
|
impl Deref for Validations {
|
|
type Target = Vec<Validation>;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl DerefMut for Validations {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.0
|
|
}
|
|
}
|
|
|
|
/// Validation errors that can occur when verifying the layout of the
|
|
/// storage. These errors include checking the validity of the
|
|
/// `rad/sigrefs` contents and the identity of the repository.
|
|
#[derive(Debug, Error)]
|
|
pub enum Validation {
|
|
#[error("found unsigned ref `{0}`")]
|
|
UnsignedRef(RefString),
|
|
#[error("{refname}: expected {expected}, but found {actual}")]
|
|
MismatchedRef {
|
|
expected: Oid,
|
|
actual: Oid,
|
|
refname: RefString,
|
|
},
|
|
#[error("missing `refs/namespaces/{remote}/{refname}`")]
|
|
MissingRef {
|
|
remote: RemoteId,
|
|
refname: RefString,
|
|
},
|
|
#[error("missing `refs/namespaces/{0}/refs/rad/sigrefs`")]
|
|
MissingRadSigRefs(RemoteId),
|
|
}
|
|
|
|
impl Repository {
|
|
/// Open an existing repository.
|
|
pub fn open<P: AsRef<Path>>(path: P, id: RepoId) -> Result<Self, RepositoryError> {
|
|
let backend = git::raw::Repository::open_ext(
|
|
path.as_ref(),
|
|
git::raw::RepositoryOpenFlags::empty()
|
|
| git::raw::RepositoryOpenFlags::BARE
|
|
| git::raw::RepositoryOpenFlags::NO_DOTGIT
|
|
| git::raw::RepositoryOpenFlags::NO_SEARCH,
|
|
&[] as &[&std::ffi::OsStr],
|
|
)?;
|
|
|
|
Ok(Self { id, backend })
|
|
}
|
|
|
|
/// Create a new repository.
|
|
pub fn create<P: AsRef<Path>>(path: P, id: RepoId, info: &UserInfo) -> Result<Self, Error> {
|
|
let backend = git::raw::Repository::init_opts(
|
|
&path,
|
|
git::raw::RepositoryInitOptions::new()
|
|
.bare(true)
|
|
.no_reinit(true)
|
|
.external_template(false),
|
|
)?;
|
|
let mut config = backend.config()?;
|
|
|
|
config.set_str("user.name", &info.name())?;
|
|
config.set_str("user.email", &info.email())?;
|
|
|
|
Ok(Self { id, backend })
|
|
}
|
|
|
|
/// Remove an existing repository
|
|
pub fn remove(&self) -> Result<(), Error> {
|
|
let path = self.backend.path();
|
|
if path.exists() {
|
|
fs::remove_dir_all(path)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove all the remotes of a repository that are not the
|
|
/// delegates of the repository or the local peer.
|
|
///
|
|
/// N.b. failure to delete remotes or references will not result
|
|
/// in an early exit. Instead, this method continues to delete the
|
|
/// next available remote or reference.
|
|
pub fn clean(&self, local: &RemoteId) -> Result<Vec<RemoteId>, RepositoryError> {
|
|
let delegates = self
|
|
.delegates()?
|
|
.into_iter()
|
|
.map(|did| *did)
|
|
.collect::<BTreeSet<_>>();
|
|
let mut deleted = Vec::new();
|
|
for id in self.remote_ids()? {
|
|
let id = match id {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
log::error!(target: "storage", "Failed to clean up remote: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// N.b. it is fatal to delete local or delegates
|
|
if *local == id || delegates.contains(&id) {
|
|
continue;
|
|
}
|
|
|
|
let glob = git::fmt::refname!("refs/namespaces")
|
|
.join(git::fmt::Component::from(&id))
|
|
.with_pattern(git::fmt::refspec::STAR);
|
|
let refs = match self.references_glob(&glob) {
|
|
Ok(refs) => refs,
|
|
Err(e) => {
|
|
log::error!(target: "storage", "Failed to clean up remote '{id}': {e}");
|
|
continue;
|
|
}
|
|
};
|
|
for (refname, _) in refs {
|
|
if let Ok(mut r) = self.backend.find_reference(refname.as_str()) {
|
|
if let Err(e) = r.delete() {
|
|
log::error!(target: "storage", "Failed to clean up reference '{refname}': {e}");
|
|
}
|
|
} else {
|
|
log::error!(target: "storage", "Failed to clean up reference '{refname}'");
|
|
}
|
|
}
|
|
deleted.push(id);
|
|
}
|
|
|
|
Ok(deleted)
|
|
}
|
|
|
|
/// Create the repository's identity branch.
|
|
pub fn init<G, S>(
|
|
doc: &Doc,
|
|
storage: &S,
|
|
signer: &Device<G>,
|
|
) -> Result<(Self, crate::git::Oid), RepositoryError>
|
|
where
|
|
G: crypto::signature::Signer<crypto::Signature>,
|
|
S: WriteStorage,
|
|
{
|
|
let (doc_oid, doc_bytes) = doc.encode()?;
|
|
let id = RepoId::from(doc_oid);
|
|
let repo = Self::create(paths::repository(storage, &id), id, storage.info())?;
|
|
let oid = repo.backend.blob(&doc_bytes)?; // Store document blob in repository.
|
|
|
|
debug_assert_eq!(doc_oid, oid);
|
|
|
|
let commit = doc.init(&repo, signer)?;
|
|
|
|
Ok((repo, commit))
|
|
}
|
|
|
|
pub fn inspect(&self) -> Result<(), Error> {
|
|
for r in self.backend.references()? {
|
|
let r = r?;
|
|
let name = r.name().ok_or(Error::InvalidRef)?;
|
|
let oid = r.resolve()?.target().ok_or(Error::InvalidRef)?;
|
|
|
|
println!("{oid} {name}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Iterate over all references.
|
|
pub fn references(
|
|
&self,
|
|
) -> Result<impl Iterator<Item = Result<Ref, refs::Error>> + '_, git::raw::Error> {
|
|
let refs = self
|
|
.backend
|
|
.references()?
|
|
.map(|reference| {
|
|
let r = reference?;
|
|
|
|
match Ref::try_from(r) {
|
|
Err(err) => Err(err.into()),
|
|
Ok(r) => Ok(Some(r)),
|
|
}
|
|
})
|
|
.filter_map(Result::transpose);
|
|
|
|
Ok(refs)
|
|
}
|
|
|
|
/// Get the canonical project information.
|
|
pub fn project(&self) -> Result<Project, RepositoryError> {
|
|
let head = self.identity_head()?;
|
|
let doc = self.identity_doc_at(head)?;
|
|
let proj = doc.project()?;
|
|
|
|
Ok(proj)
|
|
}
|
|
|
|
pub fn identity_doc_of(&self, remote: &RemoteId) -> Result<Doc, DocError> {
|
|
let oid = self.identity_head_of(remote)?;
|
|
Doc::load_at(oid, self).map(|d| d.into())
|
|
}
|
|
|
|
pub fn remote_ids(
|
|
&self,
|
|
) -> Result<impl Iterator<Item = Result<RemoteId, refs::Error>> + '_, git::raw::Error> {
|
|
let iter = self.backend.references_glob(SIGREFS_GLOB.as_str())?.map(
|
|
|reference| -> Result<RemoteId, refs::Error> {
|
|
let r = reference?;
|
|
let name = r.name().ok_or(refs::Error::InvalidRef)?;
|
|
let (id, _) = git::parse_ref_namespaced::<RemoteId>(name)?;
|
|
|
|
Ok(id)
|
|
},
|
|
);
|
|
Ok(iter)
|
|
}
|
|
|
|
pub fn remotes(
|
|
&self,
|
|
) -> Result<
|
|
impl Iterator<Item = Result<(RemoteId, Remote<Verified>), refs::Error>> + '_,
|
|
git::raw::Error,
|
|
> {
|
|
let remotes =
|
|
self.backend
|
|
.references_glob(SIGREFS_GLOB.as_str())?
|
|
.map(|reference| -> Result<_, _> {
|
|
let r = reference?;
|
|
let name = r.name().ok_or(refs::Error::InvalidRef)?;
|
|
let (id, _) = git::parse_ref_namespaced::<RemoteId>(name)?;
|
|
let remote = self.remote(&id)?;
|
|
|
|
Ok((id, remote))
|
|
});
|
|
Ok(remotes)
|
|
}
|
|
}
|
|
|
|
impl RemoteRepository for Repository {
|
|
fn remotes(&self) -> Result<Remotes<Verified>, refs::Error> {
|
|
let mut remotes = Vec::new();
|
|
for remote in Repository::remotes(self)? {
|
|
remotes.push(remote?);
|
|
}
|
|
Ok(Remotes::from_iter(remotes))
|
|
}
|
|
|
|
fn remote(&self, remote: &RemoteId) -> Result<Remote<Verified>, refs::Error> {
|
|
let refs = SignedRefs::load(*remote, self)?;
|
|
Ok(Remote::<Verified>::new(refs))
|
|
}
|
|
|
|
fn remote_refs_at(&self) -> Result<Vec<RefsAt>, refs::Error> {
|
|
let mut all = Vec::new();
|
|
|
|
for remote in self.remote_ids()? {
|
|
let remote = remote?;
|
|
let refs_at = RefsAt::new(self, remote)?;
|
|
|
|
all.push(refs_at);
|
|
}
|
|
Ok(all)
|
|
}
|
|
}
|
|
|
|
impl ValidateRepository for Repository {
|
|
fn validate_remote(&self, remote: &Remote<Verified>) -> Result<Validations, Error> {
|
|
// Contains a copy of the signed refs of this remote.
|
|
let mut signed = BTreeMap::from((*remote.refs).clone());
|
|
let mut failures = Validations::default();
|
|
let mut has_sigrefs = false;
|
|
|
|
// Check all repository references, making sure they are present in the signed refs map.
|
|
for (refname, oid) in self.references_of(&remote.id)? {
|
|
// Skip validation of the signed refs branch, as it is not part of `Remote`.
|
|
if refname == refs::SIGREFS_BRANCH.to_ref_string() {
|
|
has_sigrefs = true;
|
|
continue;
|
|
}
|
|
if let Some(signed_oid) = signed.remove(&refname) {
|
|
if oid != signed_oid {
|
|
failures.push(Validation::MismatchedRef {
|
|
refname,
|
|
expected: signed_oid,
|
|
actual: oid,
|
|
});
|
|
}
|
|
} else {
|
|
failures.push(Validation::UnsignedRef(refname));
|
|
}
|
|
}
|
|
|
|
if !has_sigrefs {
|
|
failures.push(Validation::MissingRadSigRefs(remote.id));
|
|
}
|
|
|
|
// The refs that are left in the map, are ones that were signed, but are not
|
|
// in the repository. If any are left, bail.
|
|
if let Some((name, _)) = signed.into_iter().next() {
|
|
failures.push(Validation::MissingRef {
|
|
refname: name,
|
|
remote: remote.id,
|
|
});
|
|
}
|
|
|
|
// Nb. As it stands, it doesn't make sense to verify a single remote's identity branch,
|
|
// since it is a COB.
|
|
|
|
Ok(failures)
|
|
}
|
|
}
|
|
|
|
impl ReadRepository for Repository {
|
|
fn id(&self) -> RepoId {
|
|
self.id
|
|
}
|
|
|
|
fn is_empty(&self) -> Result<bool, git::raw::Error> {
|
|
Ok(self.remotes()?.next().is_none())
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
self.backend.path()
|
|
}
|
|
|
|
fn blob_at<P: AsRef<Path>>(
|
|
&self,
|
|
commit_id: Oid,
|
|
path: P,
|
|
) -> Result<git::raw::Blob<'_>, git::raw::Error> {
|
|
let commit = self.backend.find_commit(git::raw::Oid::from(commit_id))?;
|
|
let tree = commit.tree()?;
|
|
let entry = tree.get_path(path.as_ref())?;
|
|
let obj = entry.to_object(&self.backend)?;
|
|
let blob = obj.into_blob().map_err(|_|
|
|
crate::git::raw::Error::new(
|
|
crate::git::raw::ErrorCode::NotFound,
|
|
crate::git::raw::ErrorClass::None,
|
|
format!("Path '{}' in tree of commit {commit_id} was expected to be a blob, but is not.", path.as_ref().display()),
|
|
)
|
|
)?;
|
|
|
|
Ok(blob)
|
|
}
|
|
|
|
fn blob(&self, oid: Oid) -> Result<git::raw::Blob<'_>, git::raw::Error> {
|
|
self.backend.find_blob(oid.into())
|
|
}
|
|
|
|
fn reference(
|
|
&self,
|
|
remote: &RemoteId,
|
|
name: &git::fmt::Qualified,
|
|
) -> Result<git::raw::Reference<'_>, git::raw::Error> {
|
|
let name = name.with_namespace(remote.into());
|
|
self.backend.find_reference(&name)
|
|
}
|
|
|
|
fn reference_oid(
|
|
&self,
|
|
remote: &RemoteId,
|
|
reference: &git::fmt::Qualified,
|
|
) -> Result<Oid, crate::git::raw::Error> {
|
|
let name = reference.with_namespace(remote.into());
|
|
let oid = self.backend.refname_to_id(&name)?;
|
|
|
|
Ok(oid.into())
|
|
}
|
|
|
|
fn commit(&self, oid: Oid) -> Result<git::raw::Commit<'_>, git::raw::Error> {
|
|
self.backend.find_commit(oid.into())
|
|
}
|
|
|
|
fn revwalk(&self, head: Oid) -> Result<git::raw::Revwalk<'_>, git::raw::Error> {
|
|
let mut revwalk = self.backend.revwalk()?;
|
|
revwalk.push(head.into())?;
|
|
|
|
Ok(revwalk)
|
|
}
|
|
|
|
fn contains(&self, oid: Oid) -> Result<bool, crate::git::raw::Error> {
|
|
self.backend.odb().map(|odb| odb.exists(oid.into()))
|
|
}
|
|
|
|
fn is_ancestor_of(&self, ancestor: Oid, head: Oid) -> Result<bool, crate::git::raw::Error> {
|
|
self.backend
|
|
.graph_descendant_of(head.into(), ancestor.into())
|
|
}
|
|
|
|
fn references_of(&self, remote: &RemoteId) -> Result<Refs, Error> {
|
|
let entries = self
|
|
.backend
|
|
.references_glob(format!("refs/namespaces/{remote}/*").as_str())?;
|
|
let mut refs = BTreeMap::new();
|
|
|
|
for e in entries {
|
|
let e = e?;
|
|
let name = e.name().ok_or(Error::InvalidRef)?;
|
|
let (_, refname) = git::parse_ref::<RemoteId>(name)?;
|
|
let oid = e.resolve()?.target().ok_or(Error::InvalidRef)?;
|
|
let (_, category, _, _) = refname.non_empty_components();
|
|
|
|
use git::fmt::{component, name};
|
|
|
|
if [
|
|
name::HEADS,
|
|
name::TAGS,
|
|
name::NOTES,
|
|
&component!("rad"),
|
|
&component!("cobs"),
|
|
]
|
|
.contains(&category.as_ref())
|
|
{
|
|
refs.insert(refname.into(), oid.into());
|
|
}
|
|
}
|
|
Ok(refs.into())
|
|
}
|
|
|
|
fn references_glob(
|
|
&self,
|
|
pattern: &PatternStr,
|
|
) -> Result<Vec<(Qualified<'_>, Oid)>, crate::git::raw::Error> {
|
|
let mut refs = Vec::new();
|
|
|
|
for r in self.backend.references_glob(pattern)? {
|
|
let r = r?;
|
|
|
|
let Some(oid) = r.resolve()?.target() else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(name) = r
|
|
.name()
|
|
.and_then(|n| git::fmt::RefStr::try_from_str(n).ok())
|
|
.and_then(git::fmt::Qualified::from_refstr)
|
|
{
|
|
refs.push((name.to_owned(), oid.into()));
|
|
}
|
|
}
|
|
Ok(refs)
|
|
}
|
|
|
|
fn identity_doc_at(&self, head: Oid) -> Result<DocAt, DocError> {
|
|
Doc::load_at(head, self)
|
|
}
|
|
|
|
fn head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
|
|
// If `HEAD` is already set locally, just return that.
|
|
if let Ok(head) = self.backend.head() {
|
|
if let Ok((name, oid)) = git::refs::qualified_from(&head) {
|
|
return Ok((name.to_owned(), oid));
|
|
}
|
|
}
|
|
self.canonical_head()
|
|
}
|
|
|
|
fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
|
|
let doc = self.identity_doc()?;
|
|
let refname = git::refs::branch(doc.project()?.default_branch());
|
|
let crefs = match doc.canonical_refs()? {
|
|
Some(crefs) => crefs,
|
|
// Fallback to constructing the default branch via the project
|
|
// payload
|
|
None => CanonicalRefs::from_iter([doc.default_branch_rule()?]),
|
|
};
|
|
Ok(crefs
|
|
.rules()
|
|
.canonical(refname, self)
|
|
.ok_or(RepositoryError::MissingBranchRule)?
|
|
.find_objects()?
|
|
.quorum()?)
|
|
.map(
|
|
|Quorum {
|
|
refname, object, ..
|
|
}| (refname, object.id()),
|
|
)
|
|
}
|
|
|
|
fn identity_head(&self) -> Result<Oid, RepositoryError> {
|
|
let result = self
|
|
.backend
|
|
.refname_to_id(CANONICAL_IDENTITY.as_str())
|
|
.map(Oid::from);
|
|
|
|
match result {
|
|
Ok(oid) => Ok(oid),
|
|
Err(err) if err.is_not_found() => self.canonical_identity_head(),
|
|
Err(err) => Err(err.into()),
|
|
}
|
|
}
|
|
|
|
fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, crate::git::raw::Error> {
|
|
self.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH)
|
|
}
|
|
|
|
fn identity_root(&self) -> Result<Oid, RepositoryError> {
|
|
let oid = self.backend.refname_to_id(CANONICAL_IDENTITY.as_str())?;
|
|
let root = self
|
|
.revwalk(oid.into())?
|
|
.last()
|
|
.ok_or(RepositoryError::Doc(DocError::Missing))??;
|
|
|
|
Ok(root.into())
|
|
}
|
|
|
|
fn identity_root_of(&self, remote: &RemoteId) -> Result<Oid, RepositoryError> {
|
|
// Remotes that run newer clients will have this reference set. For older clients,
|
|
// compute the root OID based on the identity head.
|
|
if let Ok(root) = self.reference_oid(remote, &git::refs::storage::IDENTITY_ROOT) {
|
|
return Ok(root);
|
|
}
|
|
let oid = self.identity_head_of(remote)?;
|
|
let root = self
|
|
.revwalk(oid)?
|
|
.last()
|
|
.ok_or(RepositoryError::Doc(DocError::Missing))??;
|
|
|
|
Ok(root.into())
|
|
}
|
|
|
|
fn canonical_identity_head(&self) -> Result<Oid, RepositoryError> {
|
|
for remote in self.remote_ids()? {
|
|
let remote = remote?;
|
|
// Nb. A remote may not have an identity document if the user has not contributed
|
|
// any changes to the identity COB.
|
|
let Ok(root) = self.identity_root_of(&remote) else {
|
|
continue;
|
|
};
|
|
let blob = Doc::blob_at(root, self)?;
|
|
|
|
// We've got an identity that goes back to the correct root.
|
|
if *self.id == blob.id() {
|
|
let identity = Identity::get(&root.into(), self)?;
|
|
|
|
return Ok(identity.head());
|
|
}
|
|
}
|
|
Err(DocError::Missing.into())
|
|
}
|
|
|
|
fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, crate::git::raw::Error> {
|
|
self.backend
|
|
.merge_base(left.into(), right.into())
|
|
.map(Oid::from)
|
|
}
|
|
}
|
|
|
|
impl WriteRepository for Repository {
|
|
fn set_head(&self) -> Result<SetHead, RepositoryError> {
|
|
let head_ref = refname!("HEAD");
|
|
let old = self
|
|
.raw()
|
|
.refname_to_id(&head_ref)
|
|
.ok()
|
|
.map(|oid| oid.into());
|
|
|
|
let (branch_ref, new) = self.canonical_head()?;
|
|
|
|
if old == Some(new) {
|
|
return Ok(SetHead { old, new });
|
|
}
|
|
log::debug!(target: "storage", "Setting ref: {} -> {}", &branch_ref, new);
|
|
self.raw()
|
|
.reference(&branch_ref, new.into(), true, "set-local-branch (radicle)")?;
|
|
|
|
log::debug!(target: "storage", "Setting ref: {head_ref} -> {branch_ref}");
|
|
self.raw()
|
|
.reference_symbolic(&head_ref, &branch_ref, true, "set-head (radicle)")?;
|
|
|
|
Ok(SetHead { old, new })
|
|
}
|
|
|
|
fn set_identity_head_to(&self, commit: Oid) -> Result<(), RepositoryError> {
|
|
log::debug!(target: "storage", "Setting ref: {} -> {}", *CANONICAL_IDENTITY, commit);
|
|
self.raw().reference(
|
|
CANONICAL_IDENTITY.as_str(),
|
|
commit.into(),
|
|
true,
|
|
"set-local-branch (radicle)",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn set_remote_identity_root_to(
|
|
&self,
|
|
remote: &RemoteId,
|
|
root: Oid,
|
|
) -> Result<(), RepositoryError> {
|
|
let refname = git::refs::storage::id_root(remote);
|
|
|
|
self.raw()
|
|
.reference(refname.as_str(), root.into(), true, "set-id-root (radicle)")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn set_user(&self, info: &UserInfo) -> Result<(), Error> {
|
|
let mut config = self.backend.config()?;
|
|
config.set_str("user.name", &info.name())?;
|
|
config.set_str("user.email", &info.email())?;
|
|
Ok(())
|
|
}
|
|
|
|
fn raw(&self) -> &git::raw::Repository {
|
|
&self.backend
|
|
}
|
|
}
|
|
|
|
impl SignRepository for Repository {
|
|
fn sign_refs<G: crypto::signature::Signer<crypto::Signature>>(
|
|
&self,
|
|
signer: &Device<G>,
|
|
) -> Result<SignedRefs<Verified>, RepositoryError> {
|
|
let remote = signer.public_key();
|
|
// Ensure the root reference is set, which is checked during sigref verification.
|
|
if self.identity_root_of(remote).is_err() {
|
|
self.set_remote_identity_root(remote)?;
|
|
}
|
|
let mut refs = self.references_of(remote)?;
|
|
// Don't sign the `rad/sigrefs` ref itself, and don't sign invalid OIDs.
|
|
refs.retain(|name, oid| {
|
|
name.as_refstr() != refs::SIGREFS_BRANCH.as_ref() && !oid.is_zero()
|
|
});
|
|
let signed = refs.signed(signer)?.verified(self)?;
|
|
signed.save(self)?;
|
|
|
|
Ok(signed)
|
|
}
|
|
}
|
|
|
|
pub mod trailers {
|
|
use std::str::FromStr;
|
|
|
|
use thiserror::Error;
|
|
|
|
use super::*;
|
|
use crypto::{PublicKey, PublicKeyError};
|
|
use crypto::{Signature, SignatureError};
|
|
|
|
pub const SIGNATURE_TRAILER: &str = "Rad-Signature";
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum Error {
|
|
#[error("invalid format for signature trailer")]
|
|
SignatureTrailerFormat,
|
|
#[error("invalid public key in signature trailer")]
|
|
PublicKey(#[from] PublicKeyError),
|
|
#[error("invalid signature in trailer")]
|
|
Signature(#[from] SignatureError),
|
|
}
|
|
|
|
pub fn parse_signatures(msg: &str) -> Result<HashMap<PublicKey, Signature>, Error> {
|
|
let trailers =
|
|
git::raw::message_trailers_strs(msg).map_err(|_| Error::SignatureTrailerFormat)?;
|
|
let mut signatures = HashMap::with_capacity(trailers.len());
|
|
|
|
for (key, val) in trailers.iter() {
|
|
if key == SIGNATURE_TRAILER {
|
|
if let Some((pk, sig)) = val.split_once(' ') {
|
|
let pk = PublicKey::from_str(pk)?;
|
|
let sig = Signature::from_str(sig)?;
|
|
|
|
signatures.insert(pk, sig);
|
|
} else {
|
|
return Err(Error::SignatureTrailerFormat);
|
|
}
|
|
}
|
|
}
|
|
Ok(signatures)
|
|
}
|
|
}
|
|
|
|
pub mod paths {
|
|
use std::path::PathBuf;
|
|
|
|
use super::ReadStorage;
|
|
use super::RepoId;
|
|
|
|
pub fn repository<S: ReadStorage>(storage: &S, proj: &RepoId) -> PathBuf {
|
|
storage.path().join(proj.canonical())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
|
|
use super::*;
|
|
use crate::git;
|
|
use crate::storage::refs::SIGREFS_BRANCH;
|
|
use crate::storage::{ReadRepository, ReadStorage};
|
|
use crate::test::fixtures;
|
|
|
|
#[test]
|
|
fn test_remote_refs() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let signer = Device::mock();
|
|
let storage = fixtures::storage(dir.path(), &signer).unwrap();
|
|
let inv = storage.repositories().unwrap();
|
|
let proj = inv.first().unwrap();
|
|
let mut refs = git::remote_refs(&git::Url::from(proj.rid)).unwrap();
|
|
|
|
let project = storage.repository(proj.rid).unwrap();
|
|
let remotes = project.remotes().unwrap();
|
|
|
|
// Strip the remote refs of sigrefs so we can compare them.
|
|
for remote in refs.values_mut() {
|
|
let sigref = (*SIGREFS_BRANCH).to_ref_string();
|
|
remote.remove(&sigref).unwrap();
|
|
}
|
|
|
|
let remotes = remotes
|
|
.map(|remote| remote.map(|(id, r): (RemoteId, Remote<Verified>)| (id, r.refs.into())))
|
|
.collect::<Result<_, _>>()
|
|
.unwrap();
|
|
|
|
assert_eq!(refs, remotes);
|
|
}
|
|
|
|
#[test]
|
|
fn test_references_of() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let signer = Device::mock();
|
|
let storage = Storage::open(tmp.path().join("storage"), fixtures::user()).unwrap();
|
|
|
|
transport::local::register(storage.clone());
|
|
|
|
let (rid, _, _, _) =
|
|
fixtures::project(tmp.path().join("project"), &storage, &signer).unwrap();
|
|
let repo = storage.repository(rid).unwrap();
|
|
let id = repo.identity().unwrap().head();
|
|
let cob = format!("refs/cobs/xyz.radicle.id/{id}");
|
|
|
|
let mut refs = repo
|
|
.references_of(signer.public_key())
|
|
.unwrap()
|
|
.keys()
|
|
.map(|r| r.to_string())
|
|
.collect::<Vec<_>>();
|
|
refs.sort();
|
|
|
|
assert_eq!(
|
|
refs,
|
|
vec![
|
|
&cob,
|
|
"refs/heads/master",
|
|
"refs/rad/id",
|
|
"refs/rad/root",
|
|
"refs/rad/sigrefs"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sign_refs() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut rng = fastrand::Rng::new();
|
|
let signer = Device::mock_rng(&mut rng);
|
|
let storage = Storage::open(tmp.path(), fixtures::user()).unwrap();
|
|
let alice = *signer.public_key();
|
|
let (rid, _, working, _) =
|
|
fixtures::project(tmp.path().join("project"), &storage, &signer).unwrap();
|
|
let stored = storage.repository(rid).unwrap();
|
|
let sig =
|
|
git::raw::Signature::now(&alice.to_string(), "anonymous@radicle.example.com").unwrap();
|
|
let head = working.head().unwrap().peel_to_commit().unwrap();
|
|
|
|
git::commit(
|
|
&working,
|
|
&head,
|
|
&git::fmt::RefString::try_from(format!("refs/remotes/{alice}/heads/master")).unwrap(),
|
|
"Second commit",
|
|
&sig,
|
|
&head.tree().unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let signed = stored.sign_refs(&signer).unwrap();
|
|
let remote = stored.remote(&alice).unwrap();
|
|
let mut unsigned = stored.references_of(&alice).unwrap();
|
|
|
|
// The signed refs doesn't contain the signature ref itself.
|
|
let sigref = (*SIGREFS_BRANCH).to_ref_string();
|
|
unsigned.remove(&sigref).unwrap();
|
|
|
|
assert_eq!(remote.refs, signed);
|
|
assert_eq!(*remote.refs, unsigned);
|
|
}
|
|
}
|