diff --git a/Cargo.lock b/Cargo.lock index 9bdedbaa..89164333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,6 +1478,7 @@ dependencies = [ "once_cell", "quickcheck", "quickcheck_macros", + "radicle-cob", "radicle-crypto", "radicle-git-ext", "radicle-ssh", diff --git a/radicle/Cargo.toml b/radicle/Cargo.toml index 44e075cb..a0eac4ce 100644 --- a/radicle/Cargo.toml +++ b/radicle/Cargo.toml @@ -37,6 +37,10 @@ version = "0.15.0" default-features = false features = ["vendored-libgit2"] +[dependencies.radicle-cob] +path = "../radicle-cob" +version = "0" + [dependencies.radicle-crypto] path = "../radicle-crypto" version = "0" diff --git a/radicle/src/cob.rs b/radicle/src/cob.rs new file mode 100644 index 00000000..5cd2dd55 --- /dev/null +++ b/radicle/src/cob.rs @@ -0,0 +1,117 @@ +pub use cob::{ + identity, object::collaboration::error, CollaborativeObject, Create, Entry, History, ObjectId, + TypeName, Update, +}; +use radicle_cob as cob; +use radicle_git_ext::Oid; + +use crate::{ + identity::{project::Identity, Did}, + storage::git::Repository, +}; + +/// The `Author` of a [`create`] or [`update`]. +/// +/// **Note**: `Author` implements [`identity::Identity`], but since it +/// is not content-addressed, the [`identity::Identity::content_id`] +/// returns [`git2::Oid::zero`]. This means that if the `author` is +/// set in the updates the history entries for those changes will +/// contain the zero `Oid` for the `author` field. +pub struct Author { + did: Did, +} + +impl identity::Identity for Author { + type Identifier = String; + + fn is_delegate(&self, delegation: &crypto::PublicKey) -> bool { + *self.did == *delegation + } + + fn content_id(&self) -> Oid { + git2::Oid::zero().into() + } +} + +/// Create a new [`CollaborativeObject`]. +/// +/// The `repository` is the project this collaborative object is being +/// stored under. +/// +/// The `signer` is used to cryptographically sign the changes made +/// for this update. **Note** that the public key for the signer must +/// match the key of the `Author` -- if it is set. +/// +/// The `project` is used to store its content-address in the history +/// of changes for the collaborative object. +/// +/// The `args` are the metadata for this [`CollaborativeObject`] +/// udpate. See [`Update`] for further information. +pub fn create( + repository: &Repository, + signer: S, + project: &Identity, + args: Create, +) -> Result +where + S: crypto::Signer, +{ + let namespace = *signer.public_key(); + cob::create(repository, signer, project, &namespace, args) +} + +/// Get a [`CollaborativeObject`], if it exists. +/// +/// The `repository` is the project this collaborative object is being +/// stored under. +/// +/// The `typename` is the type of object to be found, while the +/// `object_id` is the identifier for the particular object under that +/// type. +pub fn get( + repository: &Repository, + typename: &TypeName, + object_id: &ObjectId, +) -> Result, error::Retrieve> { + cob::get(repository, typename, object_id) +} + +/// List a set of [`CollaborativeObject`]. +/// +/// The `repository` is the project this collaborative object is being +/// stored under. +/// +/// The `typename` is the type of objects to listed. +pub fn list( + repository: &Repository, + typename: &TypeName, +) -> Result, error::Retrieve> { + cob::list(repository, typename) +} + +/// Update an existing [`CollaborativeObject`]. +/// +/// The `repository` is the project this collaborative object is being +/// stored under. +/// +/// The `signer` is used to cryptographically sign the changes made +/// for this update. **Note** that the public key for the signer must +/// match the key of the `Author` -- if it is set. +/// +/// The `project` is used to store its content-address in the history +/// of changes for the collaborative object. +/// +/// The `args` are the metadata for this [`CollaborativeObject`] +/// udpate. See [`Update`] for further information. +pub fn update( + repository: &Repository, + signer: S, + project: &Identity, + args: Update, +) -> Result +where + S: crypto::Signer, +{ + let namespace = *signer.public_key(); + cob::update(repository, signer, project, &namespace, args) +} diff --git a/radicle/src/git.rs b/radicle/src/git.rs index 08d8bf82..a0346ff9 100644 --- a/radicle/src/git.rs +++ b/radicle/src/git.rs @@ -73,6 +73,13 @@ pub mod refs { } pub mod storage { + use format::{ + name::component, + refspec::{self, PatternString}, + }; + + use radicle_cob as cob; + use super::*; /// Where the project's identity document is stored. @@ -106,6 +113,48 @@ pub mod refs { pub fn id(remote: &RemoteId) -> Namespaced { IDENTITY_BRANCH.with_namespace(remote.into()) } + + /// The collaborative object reference, identified by `typename` and `object_id`, under the given `remote`. + /// + /// `refs/namespaces//refs/cobs//` + /// + pub fn cob<'a>( + remote: &RemoteId, + typename: &cob::TypeName, + object_id: &cob::ObjectId, + ) -> Namespaced<'a> { + Qualified::from_components( + component!("cobs"), + Component::from(typename), + Some(object_id.into()), + ) + .with_namespace(remote.into()) + } + + /// All collaborative objects, identified by `typename` and `object_id`, for all remotes. + /// + /// `refs/namespaces/*/refs/cobs//` + /// + pub fn cobs(typename: &cob::TypeName, object_id: &cob::ObjectId) -> PatternString { + refspec::pattern!("refs/namespaces/*") + .join(refname!("refs/cobs")) + .join(Component::from(typename)) + .join(Component::from(object_id)) + } + + pub fn cob_suffix(cob: &R) -> Option<(cob::TypeName, cob::ObjectId)> + where + R: AsRef, + { + let cob = cob.as_ref().to_namespaced()?; + let cob = cob.strip_namespace(); + let (_refs, _cobs, typename, mut object_id) = cob.non_empty_components(); + let object_id = object_id + .next() + .and_then(|oid| oid.parse::().ok())?; + let typename = typename.parse::().ok()?; + Some((typename, object_id)) + } } pub mod workdir { diff --git a/radicle/src/identity/project.rs b/radicle/src/identity/project.rs index 1c0eb73a..c9717a2b 100644 --- a/radicle/src/identity/project.rs +++ b/radicle/src/identity/project.rs @@ -402,6 +402,18 @@ pub struct Identity { pub signatures: HashMap, } +impl radicle_cob::identity::Identity for Identity { + type Identifier = Oid; + + fn is_delegate(&self, delegation: &crypto::PublicKey) -> bool { + self.doc.delegates.iter().any(|d| d.matches(delegation)) + } + + fn content_id(&self) -> Oid { + self.current + } +} + impl Identity { pub fn verified(self, id: Id) -> Result, IdentityError> { // The root hash must be equal to the id. diff --git a/radicle/src/identity/project/id.rs b/radicle/src/identity/project/id.rs index df636886..88227d11 100644 --- a/radicle/src/identity/project/id.rs +++ b/radicle/src/identity/project/id.rs @@ -1,6 +1,7 @@ use std::ops::Deref; use std::{ffi::OsString, fmt, str::FromStr}; +use git_ref_format::{Component, RefString}; use thiserror::Error; use crate::crypto; @@ -99,3 +100,11 @@ impl<'de> serde::Deserialize<'de> for Id { serde_ext::string::deserialize(deserializer) } } + +impl From<&Id> for Component<'_> { + fn from(id: &Id) -> Self { + let refstr = + RefString::try_from(id.0.to_string()).expect("project id's are valid ref strings"); + Component::from_refstring(refstr).expect("project id's are valid refname components") + } +} diff --git a/radicle/src/lib.rs b/radicle/src/lib.rs index 8439b208..83ad132a 100644 --- a/radicle/src/lib.rs +++ b/radicle/src/lib.rs @@ -4,6 +4,7 @@ pub extern crate radicle_crypto as crypto; +pub mod cob; pub mod collections; pub mod git; pub mod hash; diff --git a/radicle/src/storage/git.rs b/radicle/src/storage/git.rs index 5530bc63..320018e1 100644 --- a/radicle/src/storage/git.rs +++ b/radicle/src/storage/git.rs @@ -7,6 +7,7 @@ use std::{fs, io}; use crypto::{Signer, Unverified, Verified}; use git_ref_format::refspec; use once_cell::sync::Lazy; +use radicle_cob::{self as cob, change}; use crate::git; use crate::identity; @@ -60,6 +61,28 @@ impl ProjectError { } } +#[derive(Error, Debug)] +pub enum CobObjectsError { + #[error(transparent)] + Convert(#[from] cob::object::storage::convert::Error), + #[error(transparent)] + Git(#[from] git2::Error), +} + +#[derive(Error, Debug)] +pub enum CobTypesError { + #[error(transparent)] + Convert(#[from] cob::object::storage::convert::Error), + #[error(transparent)] + Git(#[from] git2::Error), + #[error(transparent)] + ParseKey(#[from] crypto::Error), + #[error(transparent)] + ParseObjectId(#[from] cob::object::ParseObjectId), + #[error(transparent)] + RefFormat(#[from] git_ref_format::Error), +} + #[derive(Debug, Clone)] pub struct Storage { path: PathBuf, @@ -651,6 +674,116 @@ impl WriteRepository for Repository { } } +impl cob::Store for Repository {} + +impl change::Storage for Repository { + type CreateError = ::CreateError; + type LoadError = ::LoadError; + + type ObjectId = ::ObjectId; + type Author = ::Author; + type Resource = ::Resource; + type Signatures = ::Signatures; + + fn create( + &self, + author: Option, + authority: Self::Resource, + signer: &Signer, + spec: change::Create, + ) -> Result + where + Signer: crypto::Signer, + { + self.backend.create(author, authority, signer, spec) + } + + fn load(&self, id: Self::ObjectId) -> Result { + self.backend.load(id) + } +} + +impl cob::object::Storage for Repository { + type ObjectsError = CobObjectsError; + type TypesError = CobTypesError; + type UpdateError = git2::Error; + + type Identifier = RemoteId; + + fn objects( + &self, + typename: &cob::TypeName, + object_id: &cob::ObjectId, + ) -> Result { + let refs = self + .backend + .references_glob(git::refs::storage::cobs(typename, object_id).as_str())?; + let refs = refs + .map(|r| { + r.map_err(Self::ObjectsError::from).and_then(|r| { + cob::object::Reference::try_from(r).map_err(Self::ObjectsError::from) + }) + }) + .collect::, _>>()?; + Ok(refs.into()) + } + + fn types( + &self, + typename: &cob::TypeName, + ) -> Result, Self::TypesError> { + let mut references = self.backend.references()?.filter_map(|reference| { + let reference = reference.ok()?; + match RefStr::try_from_str(reference.name()?) { + Ok(name) => { + let (ty, object_id) = git::refs::storage::cob_suffix(&name)?; + if ty == *typename { + Some( + cob::object::Reference::try_from(reference) + .map_err(Self::TypesError::from) + .map(|reference| (object_id, reference)), + ) + } else { + None + } + } + Err(err) => Some(Err(err.into())), + } + }); + + references.try_fold(HashMap::new(), |mut objects, result| { + let (oid, reference) = result?; + objects + .entry(oid) + .and_modify(|objs: &mut cob::object::Objects| objs.push(reference.clone())) + .or_insert_with(|| cob::object::Objects::new(reference)); + Ok(objects) + }) + } + + fn update( + &self, + identifier: &Self::Identifier, + typename: &cob::TypeName, + object_id: &cob::ObjectId, + change: &cob::Change, + ) -> Result<(), Self::UpdateError> { + self.backend.reference( + git::refs::storage::cob(identifier, typename, object_id).as_str(), + (*change.id()).into(), + false, + &format!( + "Updating collaborative object '{}/{}' with new change {}", + typename, + object_id, + change.id() + ), + )?; + + Ok(()) + } +} + pub mod trailers { use std::str::FromStr;