412 lines
12 KiB
Rust
412 lines
12 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::{fmt, fs, io};
|
|
|
|
use git_ref_format::refspec;
|
|
use once_cell::sync::Lazy;
|
|
use radicle_git_ext as git_ext;
|
|
|
|
pub use radicle_git_ext::Oid;
|
|
|
|
use crate::collections::HashMap;
|
|
use crate::crypto::{Signer, Verified};
|
|
use crate::git;
|
|
use crate::identity::{ProjId, UserId};
|
|
use crate::storage::refs;
|
|
use crate::storage::refs::{Refs, SignedRefs};
|
|
use crate::storage::{
|
|
Error, Inventory, ReadRepository, ReadStorage, Remote, Remotes, WriteRepository, WriteStorage,
|
|
};
|
|
|
|
use super::RemoteId;
|
|
|
|
pub static RADICLE_ID_REF: Lazy<refspec::PatternString> =
|
|
Lazy::new(|| refspec::pattern!("heads/radicle/id"));
|
|
pub static REMOTES_GLOB: Lazy<refspec::PatternString> =
|
|
Lazy::new(|| refspec::pattern!("refs/remotes/*"));
|
|
pub static SIGNATURES_GLOB: Lazy<refspec::PatternString> =
|
|
Lazy::new(|| refspec::pattern!("refs/remotes/*/radicle/signature"));
|
|
|
|
pub struct Storage {
|
|
path: PathBuf,
|
|
signer: Arc<dyn Signer>,
|
|
}
|
|
|
|
impl fmt::Debug for Storage {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "Storage(..)")
|
|
}
|
|
}
|
|
|
|
impl ReadStorage for Storage {
|
|
fn user_id(&self) -> &UserId {
|
|
self.signer.public_key()
|
|
}
|
|
|
|
fn url(&self) -> git::Url {
|
|
git::Url {
|
|
scheme: git_url::Scheme::File,
|
|
host: Some(self.path.to_string_lossy().to_string()),
|
|
..git::Url::default()
|
|
}
|
|
}
|
|
|
|
fn get(&self, id: &ProjId) -> Result<Option<Remotes<Verified>>, Error> {
|
|
// TODO: Don't create a repo here if it doesn't exist?
|
|
// Perhaps for checking we could have a `contains` method?
|
|
match self.repository(id) {
|
|
Ok(r) => {
|
|
let remotes = r.remotes()?;
|
|
if remotes.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(remotes))
|
|
}
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
fn inventory(&self) -> Result<Inventory, Error> {
|
|
self.projects()
|
|
}
|
|
}
|
|
|
|
impl WriteStorage for Storage {
|
|
type Repository = Repository;
|
|
|
|
fn repository(&self, proj: &ProjId) -> Result<Self::Repository, Error> {
|
|
Repository::open(self.path.join(proj.to_string()))
|
|
}
|
|
|
|
fn sign_refs(&self, repository: &Repository) -> Result<SignedRefs<Verified>, Error> {
|
|
let remote = self.signer.public_key();
|
|
let refs = repository.references(remote)?;
|
|
let signed = refs.signed(self.signer.clone())?;
|
|
|
|
signed.save(remote, repository)?;
|
|
|
|
Ok(signed)
|
|
}
|
|
}
|
|
|
|
impl Storage {
|
|
pub fn open<P: AsRef<Path>, S: Signer + 'static>(
|
|
path: P,
|
|
signer: S,
|
|
) -> Result<Self, io::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(err),
|
|
Ok(()) => {}
|
|
}
|
|
|
|
Ok(Self {
|
|
path,
|
|
signer: Arc::new(signer),
|
|
})
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
self.path.as_path()
|
|
}
|
|
|
|
pub fn signer(&self) -> Arc<dyn Signer> {
|
|
self.signer.clone()
|
|
}
|
|
|
|
pub fn with_signer(&self, signer: impl Signer + 'static) -> Self {
|
|
Self {
|
|
path: self.path.clone(),
|
|
signer: Arc::new(signer),
|
|
}
|
|
}
|
|
|
|
pub fn projects(&self) -> Result<Vec<ProjId>, Error> {
|
|
let mut projects = Vec::new();
|
|
|
|
for result in fs::read_dir(&self.path)? {
|
|
let path = result?;
|
|
let id = ProjId::try_from(path.file_name())?;
|
|
|
|
projects.push(id);
|
|
}
|
|
Ok(projects)
|
|
}
|
|
|
|
pub fn inspect(&self) -> Result<(), Error> {
|
|
for proj in self.projects()? {
|
|
let repo = self.repository(&proj)?;
|
|
|
|
for r in repo.raw().references()? {
|
|
let r = r?;
|
|
let name = r.name().ok_or(Error::InvalidRef)?;
|
|
let oid = r.target().ok_or(Error::InvalidRef)?;
|
|
|
|
println!("{} {} {}", proj, oid, name);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub struct Repository {
|
|
pub(crate) backend: git2::Repository,
|
|
// TODO: Add project id here so we can refer to it
|
|
// in a bunch of places. We could write it to the
|
|
// git config for later.
|
|
}
|
|
|
|
impl Repository {
|
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
|
|
let backend = match git2::Repository::open_bare(path.as_ref()) {
|
|
Err(e) if git_ext::is_not_found_err(&e) => {
|
|
let backend = git2::Repository::init_opts(
|
|
path,
|
|
git2::RepositoryInitOptions::new()
|
|
.bare(true)
|
|
.no_reinit(true)
|
|
.external_template(false),
|
|
)?;
|
|
let mut config = backend.config()?;
|
|
|
|
// TODO: Get ahold of user name and/or key.
|
|
config.set_str("user.name", "radicle")?;
|
|
config.set_str("user.email", "radicle@localhost")?;
|
|
|
|
Ok(backend)
|
|
}
|
|
Ok(repo) => Ok(repo),
|
|
Err(e) => Err(e),
|
|
}?;
|
|
|
|
Ok(Self { backend })
|
|
}
|
|
|
|
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.target().ok_or(Error::InvalidRef)?;
|
|
|
|
println!("{} {}", oid, name);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl ReadRepository for Repository {
|
|
fn path(&self) -> &Path {
|
|
self.backend.path()
|
|
}
|
|
|
|
fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git_ext::Error> {
|
|
git_ext::Blob::At {
|
|
object: oid.into(),
|
|
path,
|
|
}
|
|
.get(&self.backend)
|
|
}
|
|
|
|
fn reference(
|
|
&self,
|
|
remote: &RemoteId,
|
|
name: &str,
|
|
) -> Result<Option<git2::Reference>, git2::Error> {
|
|
let name = format!("refs/remotes/{remote}/{name}");
|
|
self.backend.find_reference(&name).map(Some).or_else(|e| {
|
|
if git_ext::is_not_found_err(&e) {
|
|
Ok(None)
|
|
} else {
|
|
Err(e)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn reference_oid(&self, user: &RemoteId, reference: &str) -> Result<Option<Oid>, git2::Error> {
|
|
let reference = self.reference(user, reference)?;
|
|
Ok(reference.and_then(|r| r.target().map(|o| o.into())))
|
|
}
|
|
|
|
fn remote(&self, remote: &RemoteId) -> Result<Remote<Verified>, refs::Error> {
|
|
let refs = SignedRefs::load(remote, self)?;
|
|
Ok(Remote::new(*remote, refs))
|
|
}
|
|
|
|
fn references(&self, remote: &RemoteId) -> Result<Refs, Error> {
|
|
// TODO: Only return known refs, eg. heads/ rad/ tags/ etc..
|
|
let entries = self
|
|
.backend
|
|
.references_glob(format!("refs/remotes/{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.target().ok_or(Error::InvalidRef)?;
|
|
|
|
refs.insert(refname.to_string(), oid.into());
|
|
}
|
|
Ok(refs.into())
|
|
}
|
|
|
|
fn remotes(&self) -> Result<Remotes<Verified>, refs::Error> {
|
|
// TODO: Should use the id glob here instead of signature.
|
|
let refs = self.backend.references_glob(SIGNATURES_GLOB.as_str())?;
|
|
let mut remotes = HashMap::default();
|
|
|
|
for r in refs {
|
|
let r = r?;
|
|
let name = r.name().ok_or(refs::Error::InvalidRef)?;
|
|
let (id, _) = git::parse_ref::<RemoteId>(name)?;
|
|
let remote = self.remote(&id)?;
|
|
|
|
remotes.insert(id, remote);
|
|
}
|
|
Ok(Remotes::new(remotes))
|
|
}
|
|
}
|
|
|
|
impl WriteRepository for Repository {
|
|
/// Fetch all remotes of a project from the given URL.
|
|
fn fetch(&mut self, url: &git::Url) -> Result<(), git2::Error> {
|
|
// TODO: Have function to fetch specific remotes.
|
|
// TODO: Return meaningful info on success.
|
|
//
|
|
// Repository layout should look like this:
|
|
//
|
|
// /refs/remotes/<remote>
|
|
// /heads
|
|
// /master
|
|
// /tags
|
|
// ...
|
|
//
|
|
let url = url.to_string();
|
|
let refs: &[&str] = &["refs/remotes/*:refs/remotes/*"];
|
|
let mut remote = self.backend.remote_anonymous(&url)?;
|
|
let mut opts = git2::FetchOptions::default();
|
|
|
|
// TODO: Make sure we verify before pruning, as pruning may get us into
|
|
// a state we can't roll back.
|
|
opts.prune(git2::FetchPrune::On);
|
|
remote.fetch(refs, Some(&mut opts), None)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn raw(&self) -> &git2::Repository {
|
|
&self.backend
|
|
}
|
|
}
|
|
|
|
impl From<git2::Repository> for Repository {
|
|
fn from(backend: git2::Repository) -> Self {
|
|
Self { backend }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::git;
|
|
use crate::storage::refs::SIGNATURE_REF;
|
|
use crate::storage::{ReadStorage, WriteRepository};
|
|
use crate::test::arbitrary;
|
|
use crate::test::crypto::MockSigner;
|
|
use crate::test::fixtures;
|
|
|
|
#[test]
|
|
fn test_remote_refs() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = fixtures::storage(dir.path());
|
|
let inv = storage.inventory().unwrap();
|
|
let proj = inv.first().unwrap();
|
|
let mut refs = git::remote_refs(&git::Url {
|
|
host: Some(dir.path().to_string_lossy().to_string()),
|
|
scheme: git_url::Scheme::File,
|
|
path: format!("/{}", proj).into(),
|
|
..git::Url::default()
|
|
})
|
|
.unwrap();
|
|
|
|
let project = storage.repository(proj).unwrap();
|
|
let remotes = project.remotes().unwrap();
|
|
|
|
// Strip the remote refs of sigrefs so we can compare them.
|
|
for remote in refs.values_mut() {
|
|
remote.remove(SIGNATURE_REF).unwrap();
|
|
}
|
|
assert_eq!(refs, remotes.into());
|
|
}
|
|
|
|
#[test]
|
|
fn test_fetch() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let alice = fixtures::storage(tmp.path().join("alice"));
|
|
let bob = Storage::open(tmp.path().join("bob"), MockSigner::default()).unwrap();
|
|
let inventory = alice.inventory().unwrap();
|
|
let proj = inventory.first().unwrap();
|
|
let remotes = alice.repository(proj).unwrap().remotes().unwrap();
|
|
let refname = "heads/master";
|
|
|
|
// Have Bob fetch Alice's refs.
|
|
bob.repository(proj)
|
|
.unwrap()
|
|
.fetch(&git::Url {
|
|
host: Some(alice.path().to_string_lossy().to_string()),
|
|
scheme: git_url::Scheme::File,
|
|
path: format!("/{}", proj).into(),
|
|
..git::Url::default()
|
|
})
|
|
.unwrap();
|
|
|
|
for (id, _) in remotes.into_iter() {
|
|
let alice_repo = alice.repository(proj).unwrap();
|
|
let alice_oid = alice_repo.reference(&id, refname).unwrap().unwrap();
|
|
|
|
let bob_repo = bob.repository(proj).unwrap();
|
|
let bob_oid = bob_repo.reference(&id, refname).unwrap().unwrap();
|
|
|
|
assert_eq!(alice_oid.target(), bob_oid.target());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_sign_refs() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let mut rng = fastrand::Rng::new();
|
|
let signer = MockSigner::new(&mut rng);
|
|
let storage = Storage::open(tmp.path(), signer).unwrap();
|
|
let proj_id = arbitrary::gen::<ProjId>(1);
|
|
let alice = *storage.user_id();
|
|
let project = storage.repository(&proj_id).unwrap();
|
|
let backend = &project.backend;
|
|
let sig = git2::Signature::now(&alice.to_string(), "anonymous@radicle.xyz").unwrap();
|
|
let head = git::initial_commit(backend, &sig).unwrap();
|
|
|
|
let head = git::commit(backend, &head, "Second commit", &alice.to_string()).unwrap();
|
|
backend
|
|
.reference(
|
|
&format!("refs/remotes/{alice}/heads/master"),
|
|
head.id(),
|
|
false,
|
|
"test",
|
|
)
|
|
.unwrap();
|
|
|
|
let signed = storage.sign_refs(&project).unwrap();
|
|
let remote = project.remote(&alice).unwrap();
|
|
let mut unsigned = project.references(&alice).unwrap();
|
|
|
|
// The signed refs doesn't contain the signature ref itself.
|
|
unsigned.remove(SIGNATURE_REF).unwrap();
|
|
|
|
assert_eq!(remote.refs, signed);
|
|
assert_eq!(*remote.refs, unsigned);
|
|
}
|
|
}
|