Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Re-organize identity types and modules
Alexis Sellier committed 3 years ago
commit b37fef9f22c484ae561d5ea02e6b875e8ea339b5
parent 8735719ecc8b02765132b88e15082f097c1f568c
15 files changed +889 -855
modified 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;
modified 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;
modified 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;
modified 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));
+
    }
}
modified 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<git::Oid>,
+
    identity: Identity<git::Oid>,
    raw: &'a storage::Repository,
    witness: PhantomData<T>,
}
@@ -69,10 +70,10 @@ impl<'a, T> AsRef<storage::Repository> 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<Self, Error> {
-
        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(),
modified 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);
-

-
impl Did {
-
    pub fn encode(&self) -> String {
-
        format!("did:key:{}", self.0.to_human())
-
    }
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Identity<I> {
+
    /// 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<Verified>,
+
    /// Signatures over this identity.
+
    pub signatures: HashMap<PublicKey, Signature>,
+
}

-
    pub fn decode(input: &str) -> Result<Self, DidError> {
-
        let key = input
-
            .strip_prefix("did:key:")
-
            .ok_or_else(|| DidError::Did(input.to_owned()))?;
+
impl radicle_cob::identity::Identity for Identity<Oid> {
+
    type Identifier = Oid;

-
        crypto::PublicKey::from_str(key)
-
            .map(Did)
-
            .map_err(DidError::from)
+
    fn content_id(&self) -> Oid {
+
        self.current
    }
}

-
impl From<crypto::PublicKey> for Did {
-
    fn from(key: crypto::PublicKey) -> Self {
-
        Self(key)
-
    }
-
}
+
impl Identity<Oid> {
+
    pub fn verified(self, id: doc::Id) -> Result<Identity<doc::Id>, IdentityError> {
+
        // The root hash must be equal to the id.
+
        if self.root != *id {
+
            return Err(IdentityError::MismatchedRoot(self.root));
+
        }

-
impl From<Did> for String {
-
    fn from(other: Did) -> Self {
-
        other.encode()
+
        Ok(Identity {
+
            root: id,
+
            head: self.head,
+
            current: self.current,
+
            revision: self.revision,
+
            doc: self.doc,
+
            signatures: self.signatures,
+
        })
    }
}

-
impl TryFrom<String> for Did {
-
    type Error = DidError;
+
impl Identity<Untrusted> {
+
    pub fn load<R: ReadRepository>(
+
        remote: &RemoteId,
+
        repo: &R,
+
    ) -> Result<Identity<Oid>, IdentityError> {
+
        let head = Doc::<Untrusted>::head(remote, repo)?;
+
        let mut history = repo.revwalk(head)?.collect::<Vec<_>>();

-
    fn try_from(value: String) -> Result<Self, Self::Error> {
-
        Self::decode(&value)
-
    }
-
}
+
        // 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;

-
impl fmt::Display for Did {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        write!(f, "{}", self.encode())
-
    }
-
}
+
        let mut trusted = trusted.verified()?;
+
        let mut current = root;
+
        let mut signatures = Vec::new();

-
impl fmt::Debug for Did {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        write!(f, "Did({:?})", self.to_string())
-
    }
-
}
+
        // 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));
+
                }
+
            }

-
impl Deref for Did {
-
    type Target = PublicKey;
+
            // 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));
+
            }

-
    fn deref(&self) -> &Self::Target {
-
        &self.0
+
            trusted = untrusted;
+
            current = blob.id().into();
+
        }
+

+
        Ok(Identity {
+
            root,
+
            head,
+
            current,
+
            revision,
+
            doc: trusted,
+
            signatures: signatures.into_iter().collect(),
+
        })
    }
}
-

#[cfg(test)]
mod test {
-
    use super::*;
-
    use crate::crypto::PublicKey;
    use qcheck_macros::quickcheck;
-
    use std::collections::HashSet;
-

-
    #[quickcheck]
-
    fn prop_key_equality(a: PublicKey, b: PublicKey) {
-
        assert_ne!(a, b);
+
    use radicle_crypto::test::signer::MockSigner;
+
    use radicle_crypto::Signer as _;

-
        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();
+
    use crate::crypto::PublicKey;
+
    use crate::rad;
+
    use crate::storage::git::Storage;
+
    use crate::storage::{ReadStorage, WriteStorage};
+
    use crate::test::fixtures;

-
        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<Id> = 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!?");
    }
}
added 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<Self, DidError> {
+
        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<crypto::PublicKey> for Did {
+
    fn from(key: crypto::PublicKey) -> Self {
+
        Self(key)
+
    }
+
}
+

+
impl From<Did> for String {
+
    fn from(other: Did) -> Self {
+
        other.encode()
+
    }
+
}
+

+
impl TryFrom<String> for Did {
+
    type Error = DidError;
+

+
    fn try_from(value: String) -> Result<Self, Self::Error> {
+
        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();
+
    }
+
}
added 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<serde_json::Value> 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<V> {
+
    pub payload: BTreeMap<PayloadId, Payload>,
+
    pub delegates: NonEmpty<Did>,
+
    pub threshold: usize,
+

+
    #[serde(skip)]
+
    verified: PhantomData<V>,
+
}
+

+
impl<V> Doc<V> {
+
    pub fn head<R: ReadRepository>(remote: &RemoteId, repo: &R) -> Result<Oid, DocError> {
+
        repo.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH)
+
            .map_err(DocError::from)
+
    }
+
}
+

+
impl Doc<Verified> {
+
    pub fn encode(&self) -> Result<(git::Oid, Vec<u8>), 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<Project, PayloadError> {
+
        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<G: crypto::Signer>(&self, signer: &G) -> Result<(git::Oid, Signature), DocError> {
+
        let (oid, bytes) = self.encode()?;
+
        let sig = signer.sign(&bytes);
+

+
        Ok((oid, sig))
+
    }
+

+
    pub fn create<S: WriteStorage>(
+
        &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<R: WriteRepository>(
+
        &self,
+
        remote: &RemoteId,
+
        msg: &str,
+
        signatures: &[(&PublicKey, Signature)],
+
        repo: &R,
+
    ) -> Result<git::Oid, DocError> {
+
        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<git::Oid, DocError> {
+
        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<Unverified> {
+
    pub fn initial(project: Project, delegate: Did) -> Self {
+
        Self::new(project, NonEmpty::new(delegate), 1)
+
    }
+

+
    pub fn new(project: Project, delegates: NonEmpty<Did>, 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<Self, DocError> {
+
        serde_json::from_slice(bytes).map_err(DocError::from)
+
    }
+

+
    pub fn verified(self) -> Result<Doc<Verified>, 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<R: ReadRepository>(commit: Oid, repo: &R) -> Result<git2::Blob, DocError> {
+
        repo.blob_at(commit, Path::new(&*PATH))
+
            .map_err(DocError::from)
+
    }
+

+
    pub fn load_at<R: ReadRepository>(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<R: ReadRepository>(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::<RemoteId>(1);
+
        let proj = arbitrary::gen::<Id>(1);
+
        let repo = storage.repository(proj).unwrap();
+
        let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap();
+

+
        let err = Doc::<Unverified>::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<Verified>) {
+
        let (_, bytes) = doc.encode().unwrap();
+
        assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc);
+
    }
+
}
added radicle/src/identity/doc/id.rs
@@ -0,0 +1,139 @@
+
use std::ops::Deref;
+
use std::{ffi::OsString, fmt, str::FromStr};
+

+
use git_ref_format::{Component, RefString};
+
use thiserror::Error;
+

+
use crate::crypto;
+
use crate::git;
+
use crate::serde_ext;
+

+
pub use crypto::PublicKey;
+

+
/// Radicle identifier prefix.
+
pub const RAD_PREFIX: &str = "rad:";
+

+
#[derive(Error, Debug)]
+
pub enum IdError {
+
    #[error("invalid git object id: {0}")]
+
    InvalidOid(#[from] git2::Error),
+
    #[error(transparent)]
+
    Multibase(#[from] multibase::Error),
+
}
+

+
/// A radicle identifier. Commonly used to uniquely identify radicle projects.
+
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+
pub struct Id(git::Oid);
+

+
impl fmt::Display for Id {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        f.write_str(&self.to_human())
+
    }
+
}
+

+
impl fmt::Debug for Id {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "Id({})", self)
+
    }
+
}
+

+
impl Id {
+
    /// Format the identifier in a human-readable way.
+
    ///
+
    /// Eg. `rad:z3XncAdkZjeK9mQS5Sdc4qhw98BUX`.
+
    ///
+
    pub fn to_human(&self) -> String {
+
        format!(
+
            "{RAD_PREFIX}{}",
+
            multibase::encode(multibase::Base::Base58Btc, self.0.as_bytes())
+
        )
+
    }
+

+
    /// Parse an identifier from the human-readable format.
+
    /// Accepts strings without the radicle prefix as well,
+
    /// for convenience.
+
    pub fn from_human(s: &str) -> Result<Self, IdError> {
+
        let s = s.strip_prefix(RAD_PREFIX).unwrap_or(s);
+
        let (_, bytes) = multibase::decode(s)?;
+
        let array: git::Oid = bytes.as_slice().try_into()?;
+

+
        Ok(Self(array))
+
    }
+
}
+

+
impl FromStr for Id {
+
    type Err = IdError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        Self::from_human(s)
+
    }
+
}
+

+
impl TryFrom<OsString> for Id {
+
    type Error = IdError;
+

+
    fn try_from(value: OsString) -> Result<Self, Self::Error> {
+
        let string = value.to_string_lossy();
+
        Self::from_str(&string)
+
    }
+
}
+

+
impl From<git::Oid> for Id {
+
    fn from(oid: git::Oid) -> Self {
+
        Self(oid)
+
    }
+
}
+

+
impl From<git2::Oid> for Id {
+
    fn from(oid: git2::Oid) -> Self {
+
        Self(oid.into())
+
    }
+
}
+

+
impl Deref for Id {
+
    type Target = git::Oid;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.0
+
    }
+
}
+

+
impl serde::Serialize for Id {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: serde::Serializer,
+
    {
+
        serde_ext::string::serialize(self, serializer)
+
    }
+
}
+

+
impl<'de> serde::Deserialize<'de> for Id {
+
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+
    where
+
        D: serde::Deserializer<'de>,
+
    {
+
        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_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);
+
    }
+
}
modified 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<Project> 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),
-
}
-

-
/// 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<serde_json::Value> 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<V> {
-
    pub payload: BTreeMap<PayloadId, Payload>,
-
    pub delegates: NonEmpty<Did>,
-
    pub threshold: usize,
-

-
    #[serde(skip)]
-
    verified: PhantomData<V>,
-
}
-

-
impl Doc<Verified> {
-
    pub fn encode(&self) -> Result<(git::Oid, Vec<u8>), 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<Project, PayloadError> {
-
        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<G: crypto::Signer>(&self, signer: &G) -> Result<(git::Oid, Signature), DocError> {
-
        let (oid, bytes) = self.encode()?;
-
        let sig = signer.sign(&bytes);
-

-
        Ok((oid, sig))
-
    }
-

-
    pub fn create<S: WriteStorage>(
-
        &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<R: WriteRepository>(
-
        &self,
-
        remote: &RemoteId,
-
        msg: &str,
-
        signatures: &[(&PublicKey, Signature)],
-
        repo: &R,
-
    ) -> Result<git::Oid, DocError> {
-
        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<git::Oid, DocError> {
-
        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<Unverified> {
-
    pub fn initial(project: Project, delegate: Did) -> Self {
-
        Self::new(project, NonEmpty::new(delegate), 1)
-
    }
-

-
    pub fn new(project: Project, delegates: NonEmpty<Did>, 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<Self, DocError> {
-
        serde_json::from_slice(bytes).map_err(DocError::from)
-
    }
-

-
    pub fn verified(self) -> Result<Doc<Verified>, 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<R: ReadRepository>(commit: Oid, repo: &R) -> Result<git2::Blob, DocError> {
-
        repo.blob_at(commit, Path::new(&*PATH))
-
            .map_err(DocError::from)
-
    }
-

-
    pub fn load_at<R: ReadRepository>(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<R: ReadRepository>(remote: &RemoteId, repo: &R) -> Result<(Self, Oid), DocError> {
-
        let oid = Self::head(remote, repo)?;
-

-
        Self::load_at(oid, repo)
-
    }
-
}
-

-
impl<V> Doc<V> {
-
    pub fn head<R: ReadRepository>(remote: &RemoteId, repo: &R) -> Result<Oid, DocError> {
-
        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<I> {
-
    /// 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<Verified>,
-
    /// Signatures over this identity.
-
    pub signatures: HashMap<PublicKey, Signature>,
-
}
-

-
impl radicle_cob::identity::Identity for Identity<Oid> {
-
    type Identifier = Oid;
-

-
    fn content_id(&self) -> Oid {
-
        self.current
-
    }
-
}
-

-
impl Identity<Oid> {
-
    pub fn verified(self, id: Id) -> Result<Identity<Id>, 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<Untrusted> {
-
    pub fn load<R: ReadRepository>(
-
        remote: &RemoteId,
-
        repo: &R,
-
    ) -> Result<Identity<Oid>, IdentityError> {
-
        let head = Doc::<Untrusted>::head(remote, repo)?;
-
        let mut history = repo.revwalk(head)?.collect::<Vec<_>>();
-

-
        // 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::<RemoteId>(1);
-
        let proj = arbitrary::gen::<Id>(1);
-
        let repo = storage.repository(proj).unwrap();
-
        let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap();
-

-
        let err = Doc::<Unverified>::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<Id> = 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!?");
-
    }
+
impl From<Project> for Payload {
+
    fn from(proj: Project) -> Self {
+
        let value = serde_json::to_value(proj)
+
            .expect("Payload::from: could not convert project into value");

-
    #[quickcheck]
-
    fn prop_encode_decode(doc: Doc<Verified>) {
-
        let (_, bytes) = doc.encode().unwrap();
-
        assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc);
+
        Self::from(value)
    }
}
deleted radicle/src/identity/project/id.rs
@@ -1,125 +0,0 @@
-
use std::ops::Deref;
-
use std::{ffi::OsString, fmt, str::FromStr};
-

-
use git_ref_format::{Component, RefString};
-
use thiserror::Error;
-

-
use crate::crypto;
-
use crate::git;
-
use crate::serde_ext;
-

-
pub use crypto::PublicKey;
-

-
/// Radicle identifier prefix.
-
pub const RAD_PREFIX: &str = "rad:";
-

-
#[derive(Error, Debug)]
-
pub enum IdError {
-
    #[error("invalid git object id: {0}")]
-
    InvalidOid(#[from] git2::Error),
-
    #[error(transparent)]
-
    Multibase(#[from] multibase::Error),
-
}
-

-
/// A radicle identifier. Commonly used to uniquely identify radicle projects.
-
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
-
pub struct Id(git::Oid);
-

-
impl fmt::Display for Id {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        f.write_str(&self.to_human())
-
    }
-
}
-

-
impl fmt::Debug for Id {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        write!(f, "Id({})", self)
-
    }
-
}
-

-
impl Id {
-
    /// Format the identifier in a human-readable way.
-
    ///
-
    /// Eg. `rad:z3XncAdkZjeK9mQS5Sdc4qhw98BUX`.
-
    ///
-
    pub fn to_human(&self) -> String {
-
        format!(
-
            "{RAD_PREFIX}{}",
-
            multibase::encode(multibase::Base::Base58Btc, self.0.as_bytes())
-
        )
-
    }
-

-
    /// Parse an identifier from the human-readable format.
-
    /// Accepts strings without the radicle prefix as well,
-
    /// for convenience.
-
    pub fn from_human(s: &str) -> Result<Self, IdError> {
-
        let s = s.strip_prefix(RAD_PREFIX).unwrap_or(s);
-
        let (_, bytes) = multibase::decode(s)?;
-
        let array: git::Oid = bytes.as_slice().try_into()?;
-

-
        Ok(Self(array))
-
    }
-
}
-

-
impl FromStr for Id {
-
    type Err = IdError;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        Self::from_human(s)
-
    }
-
}
-

-
impl TryFrom<OsString> for Id {
-
    type Error = IdError;
-

-
    fn try_from(value: OsString) -> Result<Self, Self::Error> {
-
        let string = value.to_string_lossy();
-
        Self::from_str(&string)
-
    }
-
}
-

-
impl From<git::Oid> for Id {
-
    fn from(oid: git::Oid) -> Self {
-
        Self(oid)
-
    }
-
}
-

-
impl From<git2::Oid> for Id {
-
    fn from(oid: git2::Oid) -> Self {
-
        Self(oid.into())
-
    }
-
}
-

-
impl Deref for Id {
-
    type Target = git::Oid;
-

-
    fn deref(&self) -> &Self::Target {
-
        &self.0
-
    }
-
}
-

-
impl serde::Serialize for Id {
-
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-
    where
-
        S: serde::Serializer,
-
    {
-
        serde_ext::string::serialize(self, serializer)
-
    }
-
}
-

-
impl<'de> serde::Deserialize<'de> for Id {
-
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-
    where
-
        D: serde::Deserializer<'de>,
-
    {
-
        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_refstr(refstr).expect("project id's are valid refname components")
-
    }
-
}
modified 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}")]
modified 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}")]
modified 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;
modified 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::*;