1244 lines
40 KiB
Rust
1244 lines
40 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 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::storage::refs::{FeatureLevel, Refs, SignedRefs};
|
|
use crate::storage::{
|
|
ReadRepository, ReadStorage, Remote, Remotes, RepositoryInfo, SetHead, SignRepository,
|
|
WriteRepository, WriteStorage,
|
|
};
|
|
use crate::storage::{SignedRefsInfo, refs};
|
|
use crate::{git, git::Oid, node};
|
|
|
|
use crate::git::RefError;
|
|
use crate::git::UserInfo;
|
|
use crate::git::fmt::{
|
|
Qualified, RefString, refname, refspec, refspec::PatternStr, refspec::PatternString,
|
|
};
|
|
pub use crate::storage::{Error, RepositoryError};
|
|
|
|
use super::refs::{RefsAt, sigrefs};
|
|
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;
|
|
}
|
|
|
|
if let Some(ext) = path.path().extension().and_then(|s| s.to_str()) {
|
|
if ext == TempRepository::EXT {
|
|
// Skip temporary repositories
|
|
log::debug!(target: "storage", "Skipping temporary repository at '{}'", path.path().display());
|
|
continue;
|
|
} else if "lock" == ext {
|
|
// In previous versions, the extension ".lock" was used for temporary repositories.
|
|
// This is to handle those names in a backward-compatible way.
|
|
log::debug!(target: "storage", "Skipping locked repository at '{}'", path.path().display());
|
|
continue;
|
|
} else {
|
|
log::warn!(target: "storage", "Found path '{}' with unexpected extension '{ext}'", path.path().display());
|
|
}
|
|
}
|
|
|
|
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 = SignedRefsInfo::new(refs::SignedRefs::load(self.info.key, &repo))
|
|
.map_err(|err| Error::Refs(refs::Error::Read(err)))?;
|
|
|
|
let synced_at = match &refs {
|
|
SignedRefsInfo::Some(refs) => Some(node::SyncedAt::new(refs.at, &repo)?),
|
|
_ => None,
|
|
};
|
|
|
|
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 = SignedRefs::load(self.info.key, &repo)
|
|
.map_err(|err| RepositoryError::from(refs::Error::Read(err)))?
|
|
.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 = SignedRefsInfo::new(refs::SignedRefs::load(self.info.key, &repo))
|
|
.map_err(|err| Error::Refs(refs::Error::Read(err)))?;
|
|
|
|
let synced_at = match &refs {
|
|
SignedRefsInfo::Some(refs) => Some(node::SyncedAt::new(refs.at, &repo)?),
|
|
_ => None,
|
|
};
|
|
|
|
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("expected `refs/namespaces/{remote}/{refname}` at {expected} but found {actual}")]
|
|
MismatchedRef {
|
|
remote: RemoteId,
|
|
refname: RefString,
|
|
expected: Oid,
|
|
actual: Oid,
|
|
},
|
|
#[error("missing `refs/namespaces/{remote}/{refname}`")]
|
|
MissingRef {
|
|
remote: RemoteId,
|
|
refname: RefString,
|
|
},
|
|
#[error("missing `refs/namespaces/{0}/refs/rad/sigrefs`")]
|
|
MissingRadSigRefs(RemoteId),
|
|
#[error("failed to read `refs/namespaces/{remote}/refs/rad/sigrefs`: {source}")]
|
|
Read {
|
|
remote: RemoteId,
|
|
#[source]
|
|
source: crate::storage::refs::sigrefs::read::error::Read,
|
|
},
|
|
#[error(
|
|
"rejecting `refs/namespaces/{remote}/refs/rad/sigrefs` on feature level '{actual}', below required minimum '{minimum}'"
|
|
)]
|
|
InsufficientFeatureLevel {
|
|
remote: RemoteId,
|
|
actual: FeatureLevel,
|
|
minimum: FeatureLevel,
|
|
},
|
|
}
|
|
|
|
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),
|
|
)?;
|
|
|
|
{
|
|
// Even though `external_template(false)` is called above,
|
|
// libgit2 places stub files in the repository:
|
|
// https://github.com/libgit2/libgit2/blob/ca225744b992bf2bf24e9a2eb357ddef78179667/src/libgit2/repo_template.h#L50-L54
|
|
// This is helpful for a "normal" repository, directly interacted
|
|
// with by a human, but not necessary for our use case.
|
|
// Attempt to remove these files, but ignore any errors.
|
|
// An alternative solution would be to define our own template,
|
|
// but distributing that template is way more complex than
|
|
// deleting a handful of files.
|
|
let _ = fs::remove_dir_all(path.as_ref().join("hooks"));
|
|
let _ = fs::remove_dir_all(path.as_ref().join("info"));
|
|
let _ = fs::remove_file(path.as_ref().join("description"));
|
|
}
|
|
|
|
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), 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, 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, refs::Error> {
|
|
let refs = SignedRefs::load(*remote, self)?;
|
|
let refs = refs.ok_or_else(|| {
|
|
refs::Error::Read(refs::sigrefs::read::error::Read::MissingSigrefs {
|
|
namespace: *remote,
|
|
})
|
|
})?;
|
|
Ok(Remote::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) -> 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 {
|
|
remote: remote.id(),
|
|
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())
|
|
}
|
|
|
|
/// The published references of the given `remote`.
|
|
///
|
|
/// Note that this includes all references, including `refs/rad/sigrefs`.
|
|
/// This reference must be removed before signing the payload.
|
|
///
|
|
/// # Skipped References
|
|
///
|
|
/// References created by [`staging::patch`], i.e. references that begin
|
|
/// with `refs/tmp/heads`, are skipped.
|
|
///
|
|
/// [`staging::patch`]: crate::git::refs::storage::staging::patch
|
|
fn references_of(&self, remote: &RemoteId) -> Result<Refs, Error> {
|
|
let entries = self
|
|
.backend
|
|
.references_glob(format!("refs/namespaces/{remote}/*").as_str())?;
|
|
|
|
let mut refs = Refs::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, subcategory, _) = refname.non_empty_components();
|
|
|
|
match (category.as_str(), subcategory.as_str()) {
|
|
("tmp", "heads") => continue,
|
|
_ => {
|
|
refs.insert(refname.into(), oid.into());
|
|
}
|
|
}
|
|
}
|
|
Ok(refs)
|
|
}
|
|
|
|
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<Signer>(&self, signer: &Signer) -> Result<SignedRefs, RepositoryError>
|
|
where
|
|
Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
|
|
Signer: crypto::signature::Signer<crypto::Signature>,
|
|
Signer: crypto::signature::Verifier<crypto::Signature>,
|
|
{
|
|
self.sign_refs_with(signer, false)
|
|
}
|
|
|
|
fn force_sign_refs<Signer>(&self, signer: &Signer) -> Result<SignedRefs, RepositoryError>
|
|
where
|
|
Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
|
|
Signer: crypto::signature::Signer<crypto::Signature>,
|
|
Signer: crypto::signature::Verifier<crypto::Signature>,
|
|
{
|
|
self.sign_refs_with(signer, true)
|
|
}
|
|
}
|
|
|
|
impl Repository {
|
|
fn sign_refs_with<Signer>(
|
|
&self,
|
|
signer: &Signer,
|
|
force: bool,
|
|
) -> Result<SignedRefs, RepositoryError>
|
|
where
|
|
Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
|
|
Signer: crypto::signature::Signer<crypto::Signature>,
|
|
Signer: crypto::signature::Verifier<crypto::Signature>,
|
|
{
|
|
let remote = signer.verifying_key();
|
|
// Ensure the root reference is set, which is checked during sigref verification.
|
|
if self
|
|
.reference_oid(&remote, &git::refs::storage::IDENTITY_ROOT)
|
|
.is_err()
|
|
{
|
|
self.set_remote_identity_root(&remote)?;
|
|
}
|
|
|
|
let committer = refs::sigrefs::git::Committer::from_env_or_now(&remote);
|
|
|
|
let refs = self.references_of(&remote)?;
|
|
let signed = if force {
|
|
refs.force_save(remote, committer, self, signer)?
|
|
} else {
|
|
refs.save(remote, committer, self, signer)?
|
|
};
|
|
|
|
Ok(signed)
|
|
}
|
|
}
|
|
|
|
impl sigrefs::git::object::Reader for Repository {
|
|
fn read_commit(
|
|
&self,
|
|
oid: &Oid,
|
|
) -> Result<Option<Vec<u8>>, sigrefs::git::object::error::ReadCommit> {
|
|
self.backend.read_commit(oid)
|
|
}
|
|
|
|
fn read_blob(
|
|
&self,
|
|
commit: &Oid,
|
|
path: &Path,
|
|
) -> Result<Option<sigrefs::git::object::Blob>, sigrefs::git::object::error::ReadBlob> {
|
|
self.backend.read_blob(commit, path)
|
|
}
|
|
}
|
|
|
|
impl sigrefs::git::object::Writer for Repository {
|
|
fn write_tree(
|
|
&self,
|
|
refs: sigrefs::git::object::RefsEntry,
|
|
signature: sigrefs::git::object::SignatureEntry,
|
|
) -> Result<Oid, sigrefs::git::object::error::WriteTree> {
|
|
self.backend.write_tree(refs, signature)
|
|
}
|
|
|
|
fn write_commit(&self, bytes: &[u8]) -> Result<Oid, sigrefs::git::object::error::WriteCommit> {
|
|
self.backend.write_commit(bytes)
|
|
}
|
|
}
|
|
|
|
impl sigrefs::git::reference::Reader for Repository {
|
|
fn find_reference(
|
|
&self,
|
|
reference: &git::fmt::Namespaced,
|
|
) -> Result<Option<Oid>, sigrefs::git::reference::error::FindReference> {
|
|
sigrefs::git::reference::Reader::find_reference(&self.backend, reference)
|
|
}
|
|
}
|
|
|
|
impl sigrefs::git::reference::Writer for Repository {
|
|
fn write_reference(
|
|
&self,
|
|
reference: &git::fmt::Namespaced,
|
|
commit: Oid,
|
|
parent: Option<Oid>,
|
|
reflog: String,
|
|
) -> Result<(), sigrefs::git::reference::error::WriteReference> {
|
|
self.backend
|
|
.write_reference(reference, commit, parent, reflog)
|
|
}
|
|
}
|
|
|
|
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::{ReadRepository, ReadStorage};
|
|
use crate::test::fixtures;
|
|
|
|
#[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.
|
|
unsigned.remove_sigrefs().unwrap();
|
|
|
|
assert_eq!(remote.refs.refs(), signed.refs());
|
|
assert_eq!(*remote.refs.refs(), unsigned);
|
|
}
|
|
}
|