diff --git a/radicle-cli/src/commands/inspect.rs b/radicle-cli/src/commands/inspect.rs index 307c823a..c9df5880 100644 --- a/radicle-cli/src/commands/inspect.rs +++ b/radicle-cli/src/commands/inspect.rs @@ -8,8 +8,8 @@ use anyhow::{anyhow, Context as _}; use chrono::prelude::*; use json_color::{Color, Colorizer}; -use radicle::identity::project::{Doc, Untrusted}; -use radicle::identity::Id; +use radicle::identity::Untrusted; +use radicle::identity::{Doc, Id}; use radicle::storage::{ReadRepository, ReadStorage, WriteStorage}; use crate::terminal as term; diff --git a/radicle-cli/src/commands/rm.rs b/radicle-cli/src/commands/rm.rs index d9495773..f8a2e253 100644 --- a/radicle-cli/src/commands/rm.rs +++ b/radicle-cli/src/commands/rm.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use anyhow::anyhow; -use radicle::identity::project::Id; +use radicle::identity::Id; use radicle::storage::ReadStorage; use crate::commands::rad_untrack; diff --git a/radicle-cli/src/commands/untrack.rs b/radicle-cli/src/commands/untrack.rs index e1e5dcc0..e6b698c6 100644 --- a/radicle-cli/src/commands/untrack.rs +++ b/radicle-cli/src/commands/untrack.rs @@ -2,7 +2,7 @@ use std::ffi::OsString; use anyhow::{anyhow, Context as _}; -use radicle::identity::project::Id; +use radicle::identity::Id; use radicle::node::Handle; use radicle::prelude::*; use radicle::storage::WriteStorage; diff --git a/radicle-crypto/src/lib.rs b/radicle-crypto/src/lib.rs index 01128b2d..ca63da9b 100644 --- a/radicle-crypto/src/lib.rs +++ b/radicle-crypto/src/lib.rs @@ -432,4 +432,18 @@ mod tests { assert_eq!(key.to_string(), input); } + + #[quickcheck] + fn prop_key_equality(a: PublicKey, b: PublicKey) { + use std::collections::HashSet; + + assert_ne!(a, b); + + let mut hm = HashSet::new(); + + assert!(hm.insert(a)); + assert!(hm.insert(b)); + assert!(!hm.insert(a)); + assert!(!hm.insert(b)); + } } diff --git a/radicle/src/cob/store.rs b/radicle/src/cob/store.rs index ad3d2372..f875e171 100644 --- a/radicle/src/cob/store.rs +++ b/radicle/src/cob/store.rs @@ -12,7 +12,8 @@ use crate::cob::CollaborativeObject; use crate::cob::{Create, History, ObjectId, TypeName, Update}; use crate::crypto::PublicKey; use crate::git; -use crate::identity::project; +use crate::identity; +use crate::identity::Identity; use crate::prelude::*; use crate::storage::git as storage; @@ -43,7 +44,7 @@ pub enum Error { #[error("remove error: {0}")] Remove(#[from] cob::error::Remove), #[error(transparent)] - Identity(#[from] project::IdentityError), + Identity(#[from] identity::IdentityError), #[error(transparent)] Serialize(#[from] serde_json::Error), #[error("unexpected history type '{0}'")] @@ -52,10 +53,10 @@ pub enum Error { NotFound(TypeName, ObjectId), } -/// Storage for collaborative objects of a specific type `T` in a single project. +/// Storage for collaborative objects of a specific type `T` in a single repository. pub struct Store<'a, T> { whoami: PublicKey, - project: project::Identity, + identity: Identity, raw: &'a storage::Repository, witness: PhantomData, } @@ -69,10 +70,10 @@ impl<'a, T> AsRef for Store<'a, T> { impl<'a, T> Store<'a, T> { /// Open a new generic store. pub fn open(whoami: PublicKey, store: &'a storage::Repository) -> Result { - let project = project::Identity::load(&whoami, store)?; + let identity = Identity::load(&whoami, store)?; Ok(Self { - project, + identity, whoami, raw: store, witness: PhantomData, @@ -107,7 +108,7 @@ where cob::update( self.raw, signer, - &self.project, + &self.identity, signer.public_key(), Update { object_id, @@ -131,7 +132,7 @@ where let cob = cob::create( self.raw, signer, - &self.project, + &self.identity, signer.public_key(), Create { history_type: HISTORY_TYPE.to_owned(), diff --git a/radicle/src/identity.rs b/radicle/src/identity.rs index 1df881d6..a8b998b9 100644 --- a/radicle/src/identity.rs +++ b/radicle/src/identity.rs @@ -1,110 +1,173 @@ +pub mod did; +pub mod doc; pub mod project; -use std::ops::Deref; -use std::{fmt, str::FromStr}; +use std::collections::HashMap; -use serde::{Deserialize, Serialize}; +use radicle_git_ext::Oid; use thiserror::Error; use crate::crypto; +use crate::crypto::{Signature, Verified}; +use crate::git; +use crate::storage::git::trailers; +use crate::storage::{ReadRepository, RemoteId}; pub use crypto::PublicKey; -pub use project::{Doc, Id, IdError}; +pub use did::Did; +pub use doc::{Doc, Id, IdError}; +pub use project::Project; + +/// Untrusted, well-formed input. +#[derive(Clone, Copy, Debug)] +pub struct Untrusted; +/// Signed by quorum of the previous delegation. +#[derive(Clone, Copy, Debug)] +pub struct Trusted; #[derive(Error, Debug)] -pub enum DidError { - #[error("invalid did: {0}")] - Did(String), - #[error("invalid public key: {0}")] - PublicKey(#[from] crypto::PublicKeyError), +pub enum IdentityError { + #[error("git: {0}")] + GitRaw(#[from] git2::Error), + #[error("git: {0}")] + Git(#[from] git::Error), + #[error("root hash `{0}` does not match project")] + MismatchedRoot(Oid), + #[error("commit signature for {0} is invalid: {1}")] + InvalidSignature(PublicKey, crypto::Error), + #[error("commit message for {0} is invalid")] + InvalidCommitMessage(Oid), + #[error("commit trailers for {0} are invalid: {1}")] + InvalidCommitTrailers(Oid, trailers::Error), + #[error("quorum not reached: {0} signatures for a threshold of {1}")] + QuorumNotReached(usize, usize), + #[error("identity document error: {0}")] + Doc(#[from] doc::DocError), + #[error("the document root is missing")] + MissingRoot, } -#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone)] -#[serde(into = "String", try_from = "String")] -pub struct Did(crypto::PublicKey); +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Identity { + /// The head of the identity branch. This points to a commit that + /// contains the current document blob. + pub head: Oid, + /// The canonical identifier for this identity. + /// This is the object id of the initial document blob. + pub root: I, + /// The object id of the current document blob. + pub current: Oid, + /// Revision number. The initial document has a revision of `0`. + pub revision: u32, + /// The current document. + pub doc: Doc, + /// Signatures over this identity. + pub signatures: HashMap, +} -impl Did { - pub fn encode(&self) -> String { - format!("did:key:{}", self.0.to_human()) - } +impl radicle_cob::identity::Identity for Identity { + type Identifier = Oid; - pub fn decode(input: &str) -> Result { - let key = input - .strip_prefix("did:key:") - .ok_or_else(|| DidError::Did(input.to_owned()))?; - - crypto::PublicKey::from_str(key) - .map(Did) - .map_err(DidError::from) + fn content_id(&self) -> Oid { + self.current } } -impl From for Did { - fn from(key: crypto::PublicKey) -> Self { - Self(key) +impl Identity { + pub fn verified(self, id: doc::Id) -> Result, IdentityError> { + // The root hash must be equal to the id. + if self.root != *id { + return Err(IdentityError::MismatchedRoot(self.root)); + } + + Ok(Identity { + root: id, + head: self.head, + current: self.current, + revision: self.revision, + doc: self.doc, + signatures: self.signatures, + }) } } -impl From for String { - fn from(other: Did) -> Self { - other.encode() +impl Identity { + pub fn load( + remote: &RemoteId, + repo: &R, + ) -> Result, IdentityError> { + let head = Doc::::head(remote, repo)?; + let mut history = repo.revwalk(head)?.collect::>(); + + // Retrieve root document. + let root_oid = history.pop().ok_or(IdentityError::MissingRoot)??.into(); + let root_blob = Doc::blob_at(root_oid, repo)?; + let root: git::Oid = root_blob.id().into(); + let trusted = Doc::from_json(root_blob.content())?; + let revision = history.len() as u32; + + let mut trusted = trusted.verified()?; + let mut current = root; + let mut signatures = Vec::new(); + + // Traverse the history chronologically. + for oid in history.into_iter().rev() { + let oid = oid?; + let blob = Doc::blob_at(oid.into(), repo)?; + let untrusted = Doc::from_json(blob.content()).map_err(doc::DocError::from)?; + let untrusted = untrusted.verified()?; + let commit = repo.commit(oid.into())?; + let msg = commit + .message_raw() + .ok_or_else(|| IdentityError::InvalidCommitMessage(oid.into()))?; + + // Keys that signed the *current* document version. + signatures = trailers::parse_signatures(msg) + .map_err(|e| IdentityError::InvalidCommitTrailers(oid.into(), e))?; + for (pk, sig) in &signatures { + if let Err(err) = pk.verify(blob.content(), sig) { + return Err(IdentityError::InvalidSignature(*pk, err)); + } + } + + // Check that enough delegates signed this next version. + let quorum = signatures + .iter() + .filter(|(key, _)| trusted.delegates.iter().any(|d| &**d == key)) + .count(); + if quorum < trusted.threshold { + return Err(IdentityError::QuorumNotReached(quorum, trusted.threshold)); + } + + trusted = untrusted; + current = blob.id().into(); + } + + Ok(Identity { + root, + head, + current, + revision, + doc: trusted, + signatures: signatures.into_iter().collect(), + }) } } - -impl TryFrom for Did { - type Error = DidError; - - fn try_from(value: String) -> Result { - Self::decode(&value) - } -} - -impl fmt::Display for Did { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.encode()) - } -} - -impl fmt::Debug for Did { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Did({:?})", self.to_string()) - } -} - -impl Deref for Did { - type Target = PublicKey; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - #[cfg(test)] mod test { - use super::*; - use crate::crypto::PublicKey; use qcheck_macros::quickcheck; - use std::collections::HashSet; + use radicle_crypto::test::signer::MockSigner; + use radicle_crypto::Signer as _; - #[quickcheck] - fn prop_key_equality(a: PublicKey, b: PublicKey) { - assert_ne!(a, b); + use crate::crypto::PublicKey; + use crate::rad; + use crate::storage::git::Storage; + use crate::storage::{ReadStorage, WriteStorage}; + use crate::test::fixtures; - let mut hm = HashSet::new(); - - assert!(hm.insert(a)); - assert!(hm.insert(b)); - assert!(!hm.insert(a)); - assert!(!hm.insert(b)); - } - - #[quickcheck] - fn prop_from_str(input: Id) { - let encoded = input.to_string(); - let decoded = Id::from_str(&encoded).unwrap(); - - assert_eq!(input, decoded); - } + use super::did::Did; + use super::doc::PayloadId; + use super::*; #[quickcheck] fn prop_json_eq_str(pk: PublicKey, proj: Id, did: Did) { @@ -119,17 +182,103 @@ mod test { } #[test] - fn test_did_encode_decode() { - let input = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; - let Did(key) = Did::decode(input).unwrap(); + fn test_valid_identity() { + let tempdir = tempfile::tempdir().unwrap(); + let mut rng = fastrand::Rng::new(); - assert_eq!(Did::from(key).encode(), input); - } + let alice = MockSigner::new(&mut rng); + let bob = MockSigner::new(&mut rng); + let eve = MockSigner::new(&mut rng); - #[test] - fn test_did_vectors() { - Did::decode("did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp").unwrap(); - Did::decode("did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG").unwrap(); - Did::decode("did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp9WxWufuXSdxf").unwrap(); + let storage = Storage::open(tempdir.path().join("storage")).unwrap(); + let (id, _, _, _) = + fixtures::project(tempdir.path().join("copy"), &storage, &alice).unwrap(); + + // Bob and Eve fork the project from Alice. + rad::fork_remote(id, alice.public_key(), &bob, &storage).unwrap(); + rad::fork_remote(id, alice.public_key(), &eve, &storage).unwrap(); + + // TODO: In some cases we want to get the repo and the project, but don't + // want to have to create a repository object twice. Perhaps there should + // be a way of getting a project from a repo. + let mut doc = storage.get(alice.public_key(), id).unwrap().unwrap(); + let mut prj = doc.project().unwrap(); + let repo = storage.repository(id).unwrap(); + + // Make a change to the description and sign it. + prj.description += "!"; + doc.payload.insert(PayloadId::project(), prj.clone().into()); + doc.sign(&alice) + .and_then(|(_, sig)| { + doc.update( + alice.public_key(), + "Update description", + &[(alice.public_key(), sig)], + &repo, + ) + }) + .unwrap(); + + // Add Bob as a delegate, and sign it. + doc.delegate(*bob.public_key()); + doc.threshold = 2; + doc.sign(&alice) + .and_then(|(_, sig)| { + doc.update( + alice.public_key(), + "Add bob", + &[(alice.public_key(), sig)], + &repo, + ) + }) + .unwrap(); + + // Add Eve as a delegate, and sign it. + doc.delegate(*eve.public_key()); + doc.sign(&alice) + .and_then(|(_, alice_sig)| { + doc.sign(&bob).and_then(|(_, bob_sig)| { + doc.update( + alice.public_key(), + "Add eve", + &[(alice.public_key(), alice_sig), (bob.public_key(), bob_sig)], + &repo, + ) + }) + }) + .unwrap(); + + // Update description again with signatures by Eve and Bob. + prj.description += "?"; + doc.payload.insert(PayloadId::project(), prj.into()); + let (current, head) = doc + .sign(&bob) + .and_then(|(_, bob_sig)| { + doc.sign(&eve).and_then(|(blob_id, eve_sig)| { + doc.update( + alice.public_key(), + "Update description", + &[(bob.public_key(), bob_sig), (eve.public_key(), eve_sig)], + &repo, + ) + .map(|head| (blob_id, head)) + }) + }) + .unwrap(); + + let identity: Identity = Identity::load(alice.public_key(), &repo) + .unwrap() + .verified(id) + .unwrap(); + + assert_eq!(identity.signatures.len(), 2); + assert_eq!(identity.revision, 4); + assert_eq!(identity.root, id); + assert_eq!(identity.current, current); + assert_eq!(identity.head, head); + assert_eq!(identity.doc, doc); + + let doc = storage.get(alice.public_key(), id).unwrap().unwrap(); + assert_eq!(doc.project().unwrap().description, "Acme's repository!?"); } } diff --git a/radicle/src/identity/did.rs b/radicle/src/identity/did.rs new file mode 100644 index 00000000..3a6edc75 --- /dev/null +++ b/radicle/src/identity/did.rs @@ -0,0 +1,95 @@ +use std::ops::Deref; +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::crypto; + +#[derive(Error, Debug)] +pub enum DidError { + #[error("invalid did: {0}")] + Did(String), + #[error("invalid public key: {0}")] + PublicKey(#[from] crypto::PublicKeyError), +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone)] +#[serde(into = "String", try_from = "String")] +pub struct Did(crypto::PublicKey); + +impl Did { + pub fn encode(&self) -> String { + format!("did:key:{}", self.0.to_human()) + } + + pub fn decode(input: &str) -> Result { + let key = input + .strip_prefix("did:key:") + .ok_or_else(|| DidError::Did(input.to_owned()))?; + + crypto::PublicKey::from_str(key) + .map(Did) + .map_err(DidError::from) + } +} + +impl From for Did { + fn from(key: crypto::PublicKey) -> Self { + Self(key) + } +} + +impl From for String { + fn from(other: Did) -> Self { + other.encode() + } +} + +impl TryFrom for Did { + type Error = DidError; + + fn try_from(value: String) -> Result { + Self::decode(&value) + } +} + +impl fmt::Display for Did { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.encode()) + } +} + +impl fmt::Debug for Did { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Did({:?})", self.to_string()) + } +} + +impl Deref for Did { + type Target = crypto::PublicKey; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_did_encode_decode() { + let input = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; + let Did(key) = Did::decode(input).unwrap(); + + assert_eq!(Did::from(key).encode(), input); + } + + #[test] + fn test_did_vectors() { + Did::decode("did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp").unwrap(); + Did::decode("did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG").unwrap(); + Did::decode("did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp9WxWufuXSdxf").unwrap(); + } +} diff --git a/radicle/src/identity/doc.rs b/radicle/src/identity/doc.rs new file mode 100644 index 00000000..f1cf3d0d --- /dev/null +++ b/radicle/src/identity/doc.rs @@ -0,0 +1,363 @@ +mod id; + +use std::collections::BTreeMap; +use std::fmt; +use std::fmt::Write as _; +use std::marker::PhantomData; +use std::ops::Deref; +use std::path::Path; + +use nonempty::NonEmpty; +use once_cell::sync::Lazy; +use radicle_git_ext::Oid; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::crypto; +use crate::crypto::{Signature, Unverified, Verified}; +use crate::git; +use crate::identity::{project::Project, Did}; +use crate::storage; +use crate::storage::git::trailers; +use crate::storage::{ReadRepository, RemoteId, WriteRepository, WriteStorage}; + +pub use crypto::PublicKey; +pub use id::*; + +/// Path to the identity document in the identity branch. +pub static PATH: Lazy<&Path> = Lazy::new(|| Path::new("radicle.json")); +/// Maximum length of a string in the identity document. +pub const MAX_STRING_LENGTH: usize = 255; +/// Maximum number of a delegates in the identity document. +pub const MAX_DELEGATES: usize = 255; + +#[derive(Error, Debug)] +pub enum DocError { + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("invalid delegates: {0}")] + Delegates(&'static str), + #[error("invalid version `{0}`")] + Version(u32), + #[error("invalid threshold `{0}`: {1}")] + Threshold(usize, &'static str), + #[error("git: {0}")] + Git(#[from] git::Error), + #[error("git: {0}")] + RawGit(#[from] git2::Error), + #[error("storage: {0}")] + Storage(#[from] storage::Error), +} + +impl DocError { + /// Whether this error is caused by the document not being found. + pub fn is_not_found(&self) -> bool { + match self { + Self::Git(git::Error::NotFound(_)) => true, + Self::Git(git::Error::Git(e)) if git::is_not_found_err(e) => true, + _ => false, + } + } +} + +/// Identifies an identity document payload type. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +// TODO: Restrict values. +pub struct PayloadId(String); + +impl fmt::Display for PayloadId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl PayloadId { + /// Project payload type. + pub fn project() -> Self { + Self(String::from("xyz.radicle.project")) + } +} + +#[derive(Debug, Error)] +pub enum PayloadError { + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("payload '{0}' not found in identity document")] + NotFound(PayloadId), +} + +/// Payload value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Payload { + value: serde_json::Value, +} + +impl From for Payload { + fn from(value: serde_json::Value) -> Self { + Self { value } + } +} + +impl Deref for Payload { + type Target = serde_json::Value; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Doc { + pub payload: BTreeMap, + pub delegates: NonEmpty, + pub threshold: usize, + + #[serde(skip)] + verified: PhantomData, +} + +impl Doc { + pub fn head(remote: &RemoteId, repo: &R) -> Result { + repo.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH) + .map_err(DocError::from) + } +} + +impl Doc { + pub fn encode(&self) -> Result<(git::Oid, Vec), DocError> { + let mut buf = Vec::new(); + let mut serializer = + serde_json::Serializer::with_formatter(&mut buf, olpc_cjson::CanonicalFormatter::new()); + + self.serialize(&mut serializer)?; + let oid = git2::Oid::hash_object(git2::ObjectType::Blob, &buf)?; + + Ok((oid.into(), buf)) + } + + /// Attempt to add a new delegate to the document. Returns `true` if it wasn't there before. + pub fn delegate(&mut self, key: crypto::PublicKey) -> bool { + let delegate = Did::from(key); + if self.delegates.iter().all(|id| id != &delegate) { + self.delegates.push(delegate); + return true; + } + false + } + + /// Get the project payload, if it exists and is valid, out of this document. + pub fn project(&self) -> Result { + let value = self + .payload + .get(&PayloadId::project()) + .ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?; + let proj: Project = serde_json::from_value((**value).clone())?; + + Ok(proj) + } + + pub fn sign(&self, signer: &G) -> Result<(git::Oid, Signature), DocError> { + let (oid, bytes) = self.encode()?; + let sig = signer.sign(&bytes); + + Ok((oid, sig)) + } + + pub fn create( + &self, + remote: &RemoteId, + msg: &str, + storage: &S, + ) -> Result<(Id, git::Oid, S::Repository), DocError> { + let (doc_oid, doc) = self.encode()?; + let id = Id::from(doc_oid); + let repo = storage.repository(id)?; + let tree = git::write_tree(*PATH, doc.as_slice(), repo.raw())?; + let oid = Doc::commit(remote, &tree, msg, &[], repo.raw())?; + + drop(tree); + + Ok((id, oid, repo)) + } + + pub fn update( + &self, + remote: &RemoteId, + msg: &str, + signatures: &[(&PublicKey, Signature)], + repo: &R, + ) -> Result { + let mut msg = format!("{msg}\n\n"); + for (key, sig) in signatures { + writeln!(&mut msg, "{}: {key} {sig}", trailers::SIGNATURE_TRAILER) + .expect("in-memory writes don't fail"); + } + + let (_, doc) = self.encode()?; + let tree = git::write_tree(*PATH, doc.as_slice(), repo.raw())?; + let id_ref = git::refs::storage::id(remote); + let head = repo.raw().find_reference(&id_ref)?.peel_to_commit()?; + let oid = Doc::commit(remote, &tree, &msg, &[&head], repo.raw())?; + + Ok(oid) + } + + fn commit( + remote: &RemoteId, + tree: &git2::Tree, + msg: &str, + parents: &[&git2::Commit], + repo: &git2::Repository, + ) -> Result { + let sig = repo + .signature() + .or_else(|_| git2::Signature::now("radicle", remote.to_string().as_str()))?; + + let id_ref = git::refs::storage::id(remote); + let oid = repo.commit(Some(&id_ref), &sig, &sig, msg, tree, parents)?; + + Ok(oid.into()) + } +} + +impl Doc { + pub fn initial(project: Project, delegate: Did) -> Self { + Self::new(project, NonEmpty::new(delegate), 1) + } + + pub fn new(project: Project, delegates: NonEmpty, threshold: usize) -> Self { + let project = + serde_json::to_value(project).expect("Doc::initial: payload must be serializable"); + + Self { + payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]), + delegates, + threshold, + verified: PhantomData, + } + } + + pub fn from_json(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(DocError::from) + } + + pub fn verified(self) -> Result, DocError> { + if self.delegates.len() > MAX_DELEGATES { + return Err(DocError::Delegates("number of delegates cannot exceed 255")); + } + if self.delegates.is_empty() { + return Err(DocError::Delegates("delegate list cannot be empty")); + } + if self.threshold > self.delegates.len() { + return Err(DocError::Threshold( + self.threshold, + "threshold cannot exceed number of delegates", + )); + } + if self.threshold == 0 { + return Err(DocError::Threshold( + self.threshold, + "threshold cannot be zero", + )); + } + + Ok(Doc { + payload: self.payload, + delegates: self.delegates, + threshold: self.threshold, + verified: PhantomData, + }) + } + + pub fn blob_at(commit: Oid, repo: &R) -> Result { + repo.blob_at(commit, Path::new(&*PATH)) + .map_err(DocError::from) + } + + pub fn load_at(commit: Oid, repo: &R) -> Result<(Self, Oid), DocError> { + let blob = Self::blob_at(commit, repo)?; + let doc = Doc::from_json(blob.content())?; + + Ok((doc, blob.id().into())) + } + + pub fn load(remote: &RemoteId, repo: &R) -> Result<(Self, Oid), DocError> { + let oid = Self::head(remote, repo)?; + + Self::load_at(oid, repo) + } +} + +#[cfg(test)] +mod test { + use radicle_crypto::test::signer::MockSigner; + use radicle_crypto::Signer as _; + + use crate::rad; + use crate::storage::git::transport; + use crate::storage::git::Storage; + use crate::storage::WriteStorage; + use crate::test::arbitrary; + use crate::test::fixtures; + + use super::*; + use qcheck_macros::quickcheck; + + #[test] + fn test_canonical_example() { + let tempdir = tempfile::tempdir().unwrap(); + let storage = Storage::open(tempdir.path().join("storage")).unwrap(); + + transport::local::register(storage.clone()); + + let delegate = MockSigner::from_seed([0xff; 32]); + let (repo, _) = fixtures::repository(tempdir.path().join("working")); + let (id, _, _) = rad::init( + &repo, + "heartwood", + "Radicle Heartwood Protocol & Stack", + git::refname!("master"), + &delegate, + &storage, + ) + .unwrap(); + + assert_eq!( + delegate.public_key().to_human(), + String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi") + ); + assert_eq!( + (*id).to_string(), + "d96f425412c9f8ad5d9a9a05c9831d0728e2338d" + ); + assert_eq!( + id.to_human(), + String::from("rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji") + ); + } + + #[test] + fn test_not_found() { + let tempdir = tempfile::tempdir().unwrap(); + let storage = Storage::open(tempdir.path().join("storage")).unwrap(); + let remote = arbitrary::gen::(1); + let proj = arbitrary::gen::(1); + let repo = storage.repository(proj).unwrap(); + let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap(); + + let err = Doc::::head(&remote, &repo).unwrap_err(); + assert!(err.is_not_found()); + + let err = Doc::load_at(oid.into(), &repo).unwrap_err(); + assert!(err.is_not_found()); + } + + #[quickcheck] + fn prop_encode_decode(doc: Doc) { + let (_, bytes) = doc.encode().unwrap(); + assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc); + } +} diff --git a/radicle/src/identity/project/id.rs b/radicle/src/identity/doc/id.rs similarity index 91% rename from radicle/src/identity/project/id.rs rename to radicle/src/identity/doc/id.rs index a90ada71..6c73d076 100644 --- a/radicle/src/identity/project/id.rs +++ b/radicle/src/identity/doc/id.rs @@ -123,3 +123,17 @@ impl From<&Id> for Component<'_> { Component::from_refstr(refstr).expect("project id's are valid refname components") } } + +#[cfg(test)] +mod test { + use super::*; + use qcheck_macros::quickcheck; + + #[quickcheck] + fn prop_from_str(input: Id) { + let encoded = input.to_string(); + let decoded = Id::from_str(&encoded).unwrap(); + + assert_eq!(input, decoded); + } +} diff --git a/radicle/src/identity/project.rs b/radicle/src/identity/project.rs index cd76822f..cc3dd6ad 100644 --- a/radicle/src/identity/project.rs +++ b/radicle/src/identity/project.rs @@ -1,65 +1,14 @@ -mod id; - -use std::collections::{BTreeMap, HashMap}; -use std::fmt::{self, Write as _}; -use std::marker::PhantomData; -use std::ops::Deref; -use std::path::Path; - -use nonempty::NonEmpty; -use once_cell::sync::Lazy; -use radicle_git_ext::Oid; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::crypto; -use crate::crypto::{Signature, Unverified, Verified}; -use crate::git; -use crate::identity::Did; -use crate::storage; -use crate::storage::git::trailers; -use crate::storage::{BranchName, ReadRepository, RemoteId, WriteRepository, WriteStorage}; +use crate::identity::doc; +use crate::identity::doc::Payload; +use crate::storage::BranchName; pub use crypto::PublicKey; -pub use id::*; - -/// Untrusted, well-formed input. -#[derive(Clone, Copy, Debug)] -pub struct Untrusted; -/// Signed by quorum of the previous delegation. -#[derive(Clone, Copy, Debug)] -pub struct Trusted; - -/// Path to the identity document in the identity branch. -pub static PATH: Lazy<&Path> = Lazy::new(|| Path::new("radicle.json")); -/// Maximum length of a string in the identity document. -pub const MAX_STRING_LENGTH: usize = 255; -/// Maximum number of a delegates in the identity document. -pub const MAX_DELEGATES: usize = 255; - -#[derive(Error, Debug)] -pub enum DocError { - #[error("json: {0}")] - Json(#[from] serde_json::Error), - #[error("git: {0}")] - Git(#[from] git::Error), - #[error("git: {0}")] - RawGit(#[from] git2::Error), - #[error("storage: {0}")] - Storage(#[from] storage::Error), -} - -impl DocError { - /// Whether this error is caused by the document not being found. - pub fn is_not_found(&self) -> bool { - match self { - Self::Git(git::Error::NotFound(_)) => true, - Self::Git(git::Error::Git(e)) if git::is_not_found_err(e) => true, - _ => false, - } - } -} +/// A project-related error. #[derive(Debug, Error)] pub enum ProjectError { #[error("invalid name: {0}")] @@ -68,39 +17,30 @@ pub enum ProjectError { Description(&'static str), #[error("invalid default branch: {0}")] DefaultBranch(&'static str), - #[error("json: {0}")] - Json(#[from] serde_json::Error), - #[error("project payload not found in identity document")] - NotFound, } +/// A "project" payload in an identity document. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Project { + /// Project name. pub name: String, + /// Project description. pub description: String, + /// Project default branch. pub default_branch: BranchName, } -impl From for Payload { - fn from(proj: Project) -> Self { - let value = serde_json::to_value(proj) - .expect("Payload::from: could not convert project into value"); - - Self { value } - } -} - impl Project { /// Validate the project data. pub fn validate(&self) -> Result<(), ProjectError> { if self.name.is_empty() { return Err(ProjectError::Name("name cannot be empty")); } - if self.name.len() > MAX_STRING_LENGTH { + if self.name.len() > doc::MAX_STRING_LENGTH { return Err(ProjectError::Name("name cannot exceed 255 bytes")); } - if self.description.len() > MAX_STRING_LENGTH { + if self.description.len() > doc::MAX_STRING_LENGTH { return Err(ProjectError::Description( "description cannot exceed 255 bytes", )); @@ -110,7 +50,7 @@ impl Project { "default branch cannot be empty", )); } - if self.default_branch.len() > MAX_STRING_LENGTH { + if self.default_branch.len() > doc::MAX_STRING_LENGTH { return Err(ProjectError::DefaultBranch( "default branch cannot exceed 255 bytes", )); @@ -119,553 +59,11 @@ impl Project { } } -#[derive(Debug, Error)] -pub enum PayloadError { - #[error("json: {0}")] - Json(#[from] serde_json::Error), - #[error("payload '{0}' not found in identity document")] - NotFound(PayloadId), -} +impl From for Payload { + fn from(proj: Project) -> Self { + let value = serde_json::to_value(proj) + .expect("Payload::from: could not convert project into value"); -/// Identifies an identity document payload type. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -// TODO: Restrict values. -pub struct PayloadId(String); - -impl fmt::Display for PayloadId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -impl PayloadId -where - PayloadId: Clone, -{ - /// Project payload type. - pub fn project() -> Self { - Self(String::from("xyz.radicle.project")) - } -} - -/// Payload value. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct Payload { - value: serde_json::Value, -} - -impl From for Payload { - fn from(value: serde_json::Value) -> Self { - Self { value } - } -} - -impl Deref for Payload { - type Target = serde_json::Value; - - fn deref(&self) -> &Self::Target { - &self.value - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Doc { - pub payload: BTreeMap, - pub delegates: NonEmpty, - pub threshold: usize, - - #[serde(skip)] - verified: PhantomData, -} - -impl Doc { - pub fn encode(&self) -> Result<(git::Oid, Vec), DocError> { - let mut buf = Vec::new(); - let mut serializer = - serde_json::Serializer::with_formatter(&mut buf, olpc_cjson::CanonicalFormatter::new()); - - self.serialize(&mut serializer)?; - let oid = git2::Oid::hash_object(git2::ObjectType::Blob, &buf)?; - - Ok((oid.into(), buf)) - } - - /// Attempt to add a new delegate to the document. Returns `true` if it wasn't there before. - pub fn delegate(&mut self, key: crypto::PublicKey) -> bool { - let delegate = Did::from(key); - if self.delegates.iter().all(|id| id != &delegate) { - self.delegates.push(delegate); - return true; - } - false - } - - /// Get the project payload, if it exists and is valid, out of this document. - pub fn project(&self) -> Result { - let value = self - .payload - .get(&PayloadId::project()) - .ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?; - let proj: Project = serde_json::from_value((**value).clone())?; - - Ok(proj) - } - - pub fn sign(&self, signer: &G) -> Result<(git::Oid, Signature), DocError> { - let (oid, bytes) = self.encode()?; - let sig = signer.sign(&bytes); - - Ok((oid, sig)) - } - - pub fn create( - &self, - remote: &RemoteId, - msg: &str, - storage: &S, - ) -> Result<(Id, git::Oid, S::Repository), DocError> { - let (doc_oid, doc) = self.encode()?; - let id = Id::from(doc_oid); - let repo = storage.repository(id)?; - let tree = git::write_tree(*PATH, doc.as_slice(), repo.raw())?; - let oid = Doc::commit(remote, &tree, msg, &[], repo.raw())?; - - drop(tree); - - Ok((id, oid, repo)) - } - - pub fn update( - &self, - remote: &RemoteId, - msg: &str, - signatures: &[(&PublicKey, Signature)], - repo: &R, - ) -> Result { - let mut msg = format!("{msg}\n\n"); - for (key, sig) in signatures { - writeln!(&mut msg, "{}: {key} {sig}", trailers::SIGNATURE_TRAILER) - .expect("in-memory writes don't fail"); - } - - let (_, doc) = self.encode()?; - let tree = git::write_tree(*PATH, doc.as_slice(), repo.raw())?; - let id_ref = git::refs::storage::id(remote); - let head = repo.raw().find_reference(&id_ref)?.peel_to_commit()?; - let oid = Doc::commit(remote, &tree, &msg, &[&head], repo.raw())?; - - Ok(oid) - } - - fn commit( - remote: &RemoteId, - tree: &git2::Tree, - msg: &str, - parents: &[&git2::Commit], - repo: &git2::Repository, - ) -> Result { - let sig = repo - .signature() - .or_else(|_| git2::Signature::now("radicle", remote.to_string().as_str()))?; - - let id_ref = git::refs::storage::id(remote); - let oid = repo.commit(Some(&id_ref), &sig, &sig, msg, tree, parents)?; - - Ok(oid.into()) - } -} - -#[derive(Error, Debug)] -pub enum VerificationError { - #[error("invalid delegates: {0}")] - Delegates(&'static str), - #[error("invalid version `{0}`")] - Version(u32), - #[error("invalid threshold `{0}`: {1}")] - Threshold(usize, &'static str), -} - -impl Doc { - pub fn initial(project: Project, delegate: Did) -> Self { - Self::new(project, NonEmpty::new(delegate), 1) - } - - pub fn new(project: Project, delegates: NonEmpty, threshold: usize) -> Self { - let project = - serde_json::to_value(project).expect("Doc::initial: payload must be serializable"); - - Self { - payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]), - delegates, - threshold, - verified: PhantomData, - } - } - - pub fn from_json(bytes: &[u8]) -> Result { - serde_json::from_slice(bytes).map_err(DocError::from) - } - - pub fn verified(self) -> Result, VerificationError> { - if self.delegates.len() > MAX_DELEGATES { - return Err(VerificationError::Delegates( - "number of delegates cannot exceed 255", - )); - } - if self.delegates.is_empty() { - return Err(VerificationError::Delegates( - "delegate list cannot be empty", - )); - } - if self.threshold > self.delegates.len() { - return Err(VerificationError::Threshold( - self.threshold, - "threshold cannot exceed number of delegates", - )); - } - if self.threshold == 0 { - return Err(VerificationError::Threshold( - self.threshold, - "threshold cannot be zero", - )); - } - - Ok(Doc { - payload: self.payload, - delegates: self.delegates, - threshold: self.threshold, - verified: PhantomData, - }) - } - - pub fn blob_at(commit: Oid, repo: &R) -> Result { - repo.blob_at(commit, Path::new(&*PATH)) - .map_err(DocError::from) - } - - pub fn load_at(commit: Oid, repo: &R) -> Result<(Self, Oid), DocError> { - let blob = Self::blob_at(commit, repo)?; - let doc = Doc::from_json(blob.content())?; - - Ok((doc, blob.id().into())) - } - - pub fn load(remote: &RemoteId, repo: &R) -> Result<(Self, Oid), DocError> { - let oid = Self::head(remote, repo)?; - - Self::load_at(oid, repo) - } -} - -impl Doc { - pub fn head(remote: &RemoteId, repo: &R) -> Result { - repo.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH) - .map_err(DocError::from) - } -} - -#[derive(Error, Debug)] -pub enum IdentityError { - #[error("git: {0}")] - GitRaw(#[from] git2::Error), - #[error("git: {0}")] - Git(#[from] git::Error), - #[error("verification: {0}")] - Verification(#[from] VerificationError), - #[error("root hash `{0}` does not match project")] - MismatchedRoot(Oid), - #[error("commit signature for {0} is invalid: {1}")] - InvalidSignature(PublicKey, crypto::Error), - #[error("commit message for {0} is invalid")] - InvalidCommitMessage(Oid), - #[error("commit trailers for {0} are invalid: {1}")] - InvalidCommitTrailers(Oid, trailers::Error), - #[error("quorum not reached: {0} signatures for a threshold of {1}")] - QuorumNotReached(usize, usize), - #[error("identity document error: {0}")] - Doc(#[from] DocError), - #[error("the document root is missing")] - MissingRoot, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Identity { - /// The head of the identity branch. This points to a commit that - /// contains the current document blob. - pub head: Oid, - /// The canonical identifier for this identity. - /// This is the object id of the initial document blob. - pub root: I, - /// The object id of the current document blob. - pub current: Oid, - /// Revision number. The initial document has a revision of `0`. - pub revision: u32, - /// The current document. - pub doc: Doc, - /// Signatures over this identity. - pub signatures: HashMap, -} - -impl radicle_cob::identity::Identity for Identity { - type Identifier = Oid; - - 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. - if self.root != *id { - return Err(IdentityError::MismatchedRoot(self.root)); - } - - Ok(Identity { - root: id, - head: self.head, - current: self.current, - revision: self.revision, - doc: self.doc, - signatures: self.signatures, - }) - } -} - -impl Identity { - pub fn load( - remote: &RemoteId, - repo: &R, - ) -> Result, IdentityError> { - let head = Doc::::head(remote, repo)?; - let mut history = repo.revwalk(head)?.collect::>(); - - // Retrieve root document. - let root_oid = history.pop().ok_or(IdentityError::MissingRoot)??.into(); - let root_blob = Doc::blob_at(root_oid, repo)?; - let root: git::Oid = root_blob.id().into(); - let trusted = Doc::from_json(root_blob.content())?; - let revision = history.len() as u32; - - let mut trusted = trusted.verified()?; - let mut current = root; - let mut signatures = Vec::new(); - - // Traverse the history chronologically. - for oid in history.into_iter().rev() { - let oid = oid?; - let blob = Doc::blob_at(oid.into(), repo)?; - let untrusted = Doc::from_json(blob.content()).map_err(DocError::from)?; - let untrusted = untrusted.verified()?; - let commit = repo.commit(oid.into())?; - let msg = commit - .message_raw() - .ok_or_else(|| IdentityError::InvalidCommitMessage(oid.into()))?; - - // Keys that signed the *current* document version. - signatures = trailers::parse_signatures(msg) - .map_err(|e| IdentityError::InvalidCommitTrailers(oid.into(), e))?; - for (pk, sig) in &signatures { - if let Err(err) = pk.verify(blob.content(), sig) { - return Err(IdentityError::InvalidSignature(*pk, err)); - } - } - - // Check that enough delegates signed this next version. - let quorum = signatures - .iter() - .filter(|(key, _)| trusted.delegates.iter().any(|d| &**d == key)) - .count(); - if quorum < trusted.threshold { - return Err(IdentityError::QuorumNotReached(quorum, trusted.threshold)); - } - - trusted = untrusted; - current = blob.id().into(); - } - - Ok(Identity { - root, - head, - current, - revision, - doc: trusted, - signatures: signatures.into_iter().collect(), - }) - } -} - -#[cfg(test)] -mod test { - use radicle_crypto::test::signer::MockSigner; - use radicle_crypto::Signer as _; - - use crate::rad; - use crate::storage::git::transport; - use crate::storage::git::Storage; - use crate::storage::{ReadStorage, WriteStorage}; - use crate::test::arbitrary; - use crate::test::fixtures; - - use super::*; - use qcheck_macros::quickcheck; - - #[test] - fn test_canonical_example() { - let tempdir = tempfile::tempdir().unwrap(); - let storage = Storage::open(tempdir.path().join("storage")).unwrap(); - - transport::local::register(storage.clone()); - - let delegate = MockSigner::from_seed([0xff; 32]); - let (repo, _) = fixtures::repository(tempdir.path().join("working")); - let (id, _, _) = rad::init( - &repo, - "heartwood", - "Radicle Heartwood Protocol & Stack", - git::refname!("master"), - &delegate, - &storage, - ) - .unwrap(); - - assert_eq!( - delegate.public_key().to_human(), - String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi") - ); - assert_eq!( - (*id).to_string(), - "d96f425412c9f8ad5d9a9a05c9831d0728e2338d" - ); - assert_eq!( - id.to_human(), - String::from("rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji") - ); - } - - #[test] - fn test_not_found() { - let tempdir = tempfile::tempdir().unwrap(); - let storage = Storage::open(tempdir.path().join("storage")).unwrap(); - let remote = arbitrary::gen::(1); - let proj = arbitrary::gen::(1); - let repo = storage.repository(proj).unwrap(); - let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap(); - - let err = Doc::::head(&remote, &repo).unwrap_err(); - assert!(err.is_not_found()); - - let err = Doc::load_at(oid.into(), &repo).unwrap_err(); - assert!(err.is_not_found()); - } - - #[test] - fn test_valid_identity() { - let tempdir = tempfile::tempdir().unwrap(); - let mut rng = fastrand::Rng::new(); - - let alice = MockSigner::new(&mut rng); - let bob = MockSigner::new(&mut rng); - let eve = MockSigner::new(&mut rng); - - let storage = Storage::open(tempdir.path().join("storage")).unwrap(); - let (id, _, _, _) = - fixtures::project(tempdir.path().join("copy"), &storage, &alice).unwrap(); - - // Bob and Eve fork the project from Alice. - rad::fork_remote(id, alice.public_key(), &bob, &storage).unwrap(); - rad::fork_remote(id, alice.public_key(), &eve, &storage).unwrap(); - - // TODO: In some cases we want to get the repo and the project, but don't - // want to have to create a repository object twice. Perhaps there should - // be a way of getting a project from a repo. - let mut doc = storage.get(alice.public_key(), id).unwrap().unwrap(); - let mut prj = doc.project().unwrap(); - let repo = storage.repository(id).unwrap(); - - // Make a change to the description and sign it. - prj.description += "!"; - doc.payload.insert(PayloadId::project(), prj.clone().into()); - doc.sign(&alice) - .and_then(|(_, sig)| { - doc.update( - alice.public_key(), - "Update description", - &[(alice.public_key(), sig)], - &repo, - ) - }) - .unwrap(); - - // Add Bob as a delegate, and sign it. - doc.delegate(*bob.public_key()); - doc.threshold = 2; - doc.sign(&alice) - .and_then(|(_, sig)| { - doc.update( - alice.public_key(), - "Add bob", - &[(alice.public_key(), sig)], - &repo, - ) - }) - .unwrap(); - - // Add Eve as a delegate, and sign it. - doc.delegate(*eve.public_key()); - doc.sign(&alice) - .and_then(|(_, alice_sig)| { - doc.sign(&bob).and_then(|(_, bob_sig)| { - doc.update( - alice.public_key(), - "Add eve", - &[(alice.public_key(), alice_sig), (bob.public_key(), bob_sig)], - &repo, - ) - }) - }) - .unwrap(); - - // Update description again with signatures by Eve and Bob. - prj.description += "?"; - doc.payload.insert(PayloadId::project(), prj.into()); - let (current, head) = doc - .sign(&bob) - .and_then(|(_, bob_sig)| { - doc.sign(&eve).and_then(|(blob_id, eve_sig)| { - doc.update( - alice.public_key(), - "Update description", - &[(bob.public_key(), bob_sig), (eve.public_key(), eve_sig)], - &repo, - ) - .map(|head| (blob_id, head)) - }) - }) - .unwrap(); - - let identity: Identity = Identity::load(alice.public_key(), &repo) - .unwrap() - .verified(id) - .unwrap(); - - assert_eq!(identity.signatures.len(), 2); - assert_eq!(identity.revision, 4); - assert_eq!(identity.root, id); - assert_eq!(identity.current, current); - assert_eq!(identity.head, head); - assert_eq!(identity.doc, doc); - - let doc = storage.get(alice.public_key(), id).unwrap().unwrap(); - assert_eq!(doc.project().unwrap().description, "Acme's repository!?"); - } - - #[quickcheck] - fn prop_encode_decode(doc: Doc) { - let (_, bytes) = doc.encode().unwrap(); - assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc); + Self::from(value) } } diff --git a/radicle/src/rad.rs b/radicle/src/rad.rs index e5bb3fe1..0e18b271 100644 --- a/radicle/src/rad.rs +++ b/radicle/src/rad.rs @@ -8,8 +8,9 @@ use thiserror::Error; use crate::crypto::{Signer, Verified}; use crate::git; -use crate::identity::project::{self, DocError, Project}; -use crate::identity::Id; +use crate::identity::doc; +use crate::identity::doc::{DocError, Id}; +use crate::identity::project::Project; use crate::node; use crate::node::NodeId; use crate::storage::git::transport::{self, remote}; @@ -29,11 +30,9 @@ pub fn peer_remote(peer: &NodeId) -> String { #[derive(Error, Debug)] pub enum InitError { #[error("doc: {0}")] - Doc(#[from] identity::project::DocError), + Doc(#[from] DocError), #[error("project: {0}")] Project(#[from] storage::git::ProjectError), - #[error("doc: {0}")] - DocVerification(#[from] identity::project::VerificationError), #[error("git: {0}")] Git(#[from] git2::Error), #[error("i/o: {0}")] @@ -96,7 +95,7 @@ pub enum ForkError { #[error("storage: {0}")] Storage(#[from] storage::Error), #[error("payload: {0}")] - Payload(#[from] project::PayloadError), + Payload(#[from] doc::PayloadError), #[error("project `{0}` was not found in storage")] NotFound(Id), #[error("project identity error: {0}")] @@ -250,7 +249,7 @@ pub enum CheckoutError { #[error("storage: {0}")] Storage(#[from] storage::Error), #[error("payload: {0}")] - Payload(#[from] project::PayloadError), + Payload(#[from] doc::PayloadError), #[error("project `{0}` was not found in storage")] NotFound(Id), #[error("project error: {0}")] diff --git a/radicle/src/storage/git.rs b/radicle/src/storage/git.rs index d6cee07a..61350552 100644 --- a/radicle/src/storage/git.rs +++ b/radicle/src/storage/git.rs @@ -11,8 +11,8 @@ use radicle_cob::{self as cob, change}; use crate::git; use crate::identity; -use crate::identity::project::{Identity, IdentityError, Project, VerificationError}; -use crate::identity::{Doc, Id}; +use crate::identity::{doc, Doc, Id}; +use crate::identity::{Identity, IdentityError, Project}; use crate::storage::refs; use crate::storage::refs::{Refs, SignedRefs}; use crate::storage::{ @@ -40,11 +40,9 @@ pub enum ProjectError { #[error("storage error: {0}")] Storage(#[from] Error), #[error("identity document error: {0}")] - Doc(#[from] identity::project::DocError), + Doc(#[from] doc::DocError), #[error("payload error: {0}")] - Payload(#[from] identity::project::PayloadError), - #[error("verification error: {0}")] - Verification(#[from] VerificationError), + Payload(#[from] doc::PayloadError), #[error("git: {0}")] Git(#[from] git2::Error), #[error("git: {0}")] diff --git a/radicle/src/test/arbitrary.rs b/radicle/src/test/arbitrary.rs index 16fee756..18e7fdc9 100644 --- a/radicle/src/test/arbitrary.rs +++ b/radicle/src/test/arbitrary.rs @@ -10,7 +10,11 @@ use qcheck::Arbitrary; use crate::collections::HashMap; use crate::git; -use crate::identity::{project::Doc, project::Project, Did, Id}; +use crate::identity::{ + doc::{Doc, Id}, + project::Project, + Did, +}; use crate::storage; use crate::storage::refs::{Refs, SignedRefs}; use crate::test::storage::MockStorage; diff --git a/radicle/src/test/storage.rs b/radicle/src/test/storage.rs index 58d973b2..0b6e12dd 100644 --- a/radicle/src/test/storage.rs +++ b/radicle/src/test/storage.rs @@ -5,8 +5,7 @@ use git_ref_format as fmt; use radicle_git_ext as git_ext; use crate::crypto::{Signer, Verified}; -use crate::identity::project::Doc; -use crate::identity::Id; +use crate::identity::doc::{Doc, Id}; pub use crate::storage::*;