Re-organize identity types and modules
The new hierarchy should be much more correct, and cleaner: * `identity` (`Identity`) * `identity::did` (`Did`) * `identity::doc` (`Doc`, `Payload`) * `identity::doc::id` (`Id`) * `identity::project` (`Project`) Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
parent
8735719ecc
commit
b37fef9f22
|
|
@ -8,8 +8,8 @@ use anyhow::{anyhow, Context as _};
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use json_color::{Color, Colorizer};
|
use json_color::{Color, Colorizer};
|
||||||
|
|
||||||
use radicle::identity::project::{Doc, Untrusted};
|
use radicle::identity::Untrusted;
|
||||||
use radicle::identity::Id;
|
use radicle::identity::{Doc, Id};
|
||||||
use radicle::storage::{ReadRepository, ReadStorage, WriteStorage};
|
use radicle::storage::{ReadRepository, ReadStorage, WriteStorage};
|
||||||
|
|
||||||
use crate::terminal as term;
|
use crate::terminal as term;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
|
|
||||||
use radicle::identity::project::Id;
|
use radicle::identity::Id;
|
||||||
use radicle::storage::ReadStorage;
|
use radicle::storage::ReadStorage;
|
||||||
|
|
||||||
use crate::commands::rad_untrack;
|
use crate::commands::rad_untrack;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::ffi::OsString;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as _};
|
use anyhow::{anyhow, Context as _};
|
||||||
|
|
||||||
use radicle::identity::project::Id;
|
use radicle::identity::Id;
|
||||||
use radicle::node::Handle;
|
use radicle::node::Handle;
|
||||||
use radicle::prelude::*;
|
use radicle::prelude::*;
|
||||||
use radicle::storage::WriteStorage;
|
use radicle::storage::WriteStorage;
|
||||||
|
|
|
||||||
|
|
@ -432,4 +432,18 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(key.to_string(), input);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ use crate::cob::CollaborativeObject;
|
||||||
use crate::cob::{Create, History, ObjectId, TypeName, Update};
|
use crate::cob::{Create, History, ObjectId, TypeName, Update};
|
||||||
use crate::crypto::PublicKey;
|
use crate::crypto::PublicKey;
|
||||||
use crate::git;
|
use crate::git;
|
||||||
use crate::identity::project;
|
use crate::identity;
|
||||||
|
use crate::identity::Identity;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::storage::git as storage;
|
use crate::storage::git as storage;
|
||||||
|
|
||||||
|
|
@ -43,7 +44,7 @@ pub enum Error {
|
||||||
#[error("remove error: {0}")]
|
#[error("remove error: {0}")]
|
||||||
Remove(#[from] cob::error::Remove),
|
Remove(#[from] cob::error::Remove),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Identity(#[from] project::IdentityError),
|
Identity(#[from] identity::IdentityError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Serialize(#[from] serde_json::Error),
|
Serialize(#[from] serde_json::Error),
|
||||||
#[error("unexpected history type '{0}'")]
|
#[error("unexpected history type '{0}'")]
|
||||||
|
|
@ -52,10 +53,10 @@ pub enum Error {
|
||||||
NotFound(TypeName, ObjectId),
|
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> {
|
pub struct Store<'a, T> {
|
||||||
whoami: PublicKey,
|
whoami: PublicKey,
|
||||||
project: project::Identity<git::Oid>,
|
identity: Identity<git::Oid>,
|
||||||
raw: &'a storage::Repository,
|
raw: &'a storage::Repository,
|
||||||
witness: PhantomData<T>,
|
witness: PhantomData<T>,
|
||||||
}
|
}
|
||||||
|
|
@ -69,10 +70,10 @@ impl<'a, T> AsRef<storage::Repository> for Store<'a, T> {
|
||||||
impl<'a, T> Store<'a, T> {
|
impl<'a, T> Store<'a, T> {
|
||||||
/// Open a new generic store.
|
/// Open a new generic store.
|
||||||
pub fn open(whoami: PublicKey, store: &'a storage::Repository) -> Result<Self, Error> {
|
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 {
|
Ok(Self {
|
||||||
project,
|
identity,
|
||||||
whoami,
|
whoami,
|
||||||
raw: store,
|
raw: store,
|
||||||
witness: PhantomData,
|
witness: PhantomData,
|
||||||
|
|
@ -107,7 +108,7 @@ where
|
||||||
cob::update(
|
cob::update(
|
||||||
self.raw,
|
self.raw,
|
||||||
signer,
|
signer,
|
||||||
&self.project,
|
&self.identity,
|
||||||
signer.public_key(),
|
signer.public_key(),
|
||||||
Update {
|
Update {
|
||||||
object_id,
|
object_id,
|
||||||
|
|
@ -131,7 +132,7 @@ where
|
||||||
let cob = cob::create(
|
let cob = cob::create(
|
||||||
self.raw,
|
self.raw,
|
||||||
signer,
|
signer,
|
||||||
&self.project,
|
&self.identity,
|
||||||
signer.public_key(),
|
signer.public_key(),
|
||||||
Create {
|
Create {
|
||||||
history_type: HISTORY_TYPE.to_owned(),
|
history_type: HISTORY_TYPE.to_owned(),
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,173 @@
|
||||||
|
pub mod did;
|
||||||
|
pub mod doc;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
|
||||||
use std::ops::Deref;
|
use std::collections::HashMap;
|
||||||
use std::{fmt, str::FromStr};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use radicle_git_ext::Oid;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::crypto;
|
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 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)]
|
#[derive(Error, Debug)]
|
||||||
pub enum DidError {
|
pub enum IdentityError {
|
||||||
#[error("invalid did: {0}")]
|
#[error("git: {0}")]
|
||||||
Did(String),
|
GitRaw(#[from] git2::Error),
|
||||||
#[error("invalid public key: {0}")]
|
#[error("git: {0}")]
|
||||||
PublicKey(#[from] crypto::PublicKeyError),
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
#[serde(into = "String", try_from = "String")]
|
pub struct Identity<I> {
|
||||||
pub struct Did(crypto::PublicKey);
|
/// 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 Did {
|
impl radicle_cob::identity::Identity for Identity<Oid> {
|
||||||
pub fn encode(&self) -> String {
|
type Identifier = Oid;
|
||||||
format!("did:key:{}", self.0.to_human())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode(input: &str) -> Result<Self, DidError> {
|
fn content_id(&self) -> Oid {
|
||||||
let key = input
|
self.current
|
||||||
.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 {
|
impl Identity<Oid> {
|
||||||
fn from(key: crypto::PublicKey) -> Self {
|
pub fn verified(self, id: doc::Id) -> Result<Identity<doc::Id>, IdentityError> {
|
||||||
Self(key)
|
// 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<Did> for String {
|
impl Identity<Untrusted> {
|
||||||
fn from(other: Did) -> Self {
|
pub fn load<R: ReadRepository>(
|
||||||
other.encode()
|
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(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<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 = PublicKey;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
|
||||||
use crate::crypto::PublicKey;
|
|
||||||
use qcheck_macros::quickcheck;
|
use qcheck_macros::quickcheck;
|
||||||
use std::collections::HashSet;
|
use radicle_crypto::test::signer::MockSigner;
|
||||||
|
use radicle_crypto::Signer as _;
|
||||||
|
|
||||||
#[quickcheck]
|
use crate::crypto::PublicKey;
|
||||||
fn prop_key_equality(a: PublicKey, b: PublicKey) {
|
use crate::rad;
|
||||||
assert_ne!(a, b);
|
use crate::storage::git::Storage;
|
||||||
|
use crate::storage::{ReadStorage, WriteStorage};
|
||||||
|
use crate::test::fixtures;
|
||||||
|
|
||||||
let mut hm = HashSet::new();
|
use super::did::Did;
|
||||||
|
use super::doc::PayloadId;
|
||||||
assert!(hm.insert(a));
|
use super::*;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[quickcheck]
|
#[quickcheck]
|
||||||
fn prop_json_eq_str(pk: PublicKey, proj: Id, did: Did) {
|
fn prop_json_eq_str(pk: PublicKey, proj: Id, did: Did) {
|
||||||
|
|
@ -119,17 +182,103 @@ mod test {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_did_encode_decode() {
|
fn test_valid_identity() {
|
||||||
let input = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
|
let tempdir = tempfile::tempdir().unwrap();
|
||||||
let Did(key) = Did::decode(input).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]
|
let storage = Storage::open(tempdir.path().join("storage")).unwrap();
|
||||||
fn test_did_vectors() {
|
let (id, _, _, _) =
|
||||||
Did::decode("did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp").unwrap();
|
fixtures::project(tempdir.path().join("copy"), &storage, &alice).unwrap();
|
||||||
Did::decode("did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG").unwrap();
|
|
||||||
Did::decode("did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp9WxWufuXSdxf").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!?");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -123,3 +123,17 @@ impl From<&Id> for Component<'_> {
|
||||||
Component::from_refstr(refstr).expect("project id's are valid refname components")
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::crypto::{Signature, Unverified, Verified};
|
use crate::identity::doc;
|
||||||
use crate::git;
|
use crate::identity::doc::Payload;
|
||||||
use crate::identity::Did;
|
use crate::storage::BranchName;
|
||||||
use crate::storage;
|
|
||||||
use crate::storage::git::trailers;
|
|
||||||
use crate::storage::{BranchName, ReadRepository, RemoteId, WriteRepository, WriteStorage};
|
|
||||||
|
|
||||||
pub use crypto::PublicKey;
|
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)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ProjectError {
|
pub enum ProjectError {
|
||||||
#[error("invalid name: {0}")]
|
#[error("invalid name: {0}")]
|
||||||
|
|
@ -68,39 +17,30 @@ pub enum ProjectError {
|
||||||
Description(&'static str),
|
Description(&'static str),
|
||||||
#[error("invalid default branch: {0}")]
|
#[error("invalid default branch: {0}")]
|
||||||
DefaultBranch(&'static str),
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
|
/// Project name.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Project description.
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
/// Project default branch.
|
||||||
pub default_branch: BranchName,
|
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 {
|
impl Project {
|
||||||
/// Validate the project data.
|
/// Validate the project data.
|
||||||
pub fn validate(&self) -> Result<(), ProjectError> {
|
pub fn validate(&self) -> Result<(), ProjectError> {
|
||||||
if self.name.is_empty() {
|
if self.name.is_empty() {
|
||||||
return Err(ProjectError::Name("name cannot be 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"));
|
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(
|
return Err(ProjectError::Description(
|
||||||
"description cannot exceed 255 bytes",
|
"description cannot exceed 255 bytes",
|
||||||
));
|
));
|
||||||
|
|
@ -110,7 +50,7 @@ impl Project {
|
||||||
"default branch cannot be empty",
|
"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(
|
return Err(ProjectError::DefaultBranch(
|
||||||
"default branch cannot exceed 255 bytes",
|
"default branch cannot exceed 255 bytes",
|
||||||
));
|
));
|
||||||
|
|
@ -119,553 +59,11 @@ impl Project {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
impl From<Project> for Payload {
|
||||||
pub enum PayloadError {
|
fn from(proj: Project) -> Self {
|
||||||
#[error("json: {0}")]
|
let value = serde_json::to_value(proj)
|
||||||
Json(#[from] serde_json::Error),
|
.expect("Payload::from: could not convert project into value");
|
||||||
#[error("payload '{0}' not found in identity document")]
|
|
||||||
NotFound(PayloadId),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Identifies an identity document payload type.
|
Self::from(value)
|
||||||
#[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!?");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[quickcheck]
|
|
||||||
fn prop_encode_decode(doc: Doc<Verified>) {
|
|
||||||
let (_, bytes) = doc.encode().unwrap();
|
|
||||||
assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ use thiserror::Error;
|
||||||
|
|
||||||
use crate::crypto::{Signer, Verified};
|
use crate::crypto::{Signer, Verified};
|
||||||
use crate::git;
|
use crate::git;
|
||||||
use crate::identity::project::{self, DocError, Project};
|
use crate::identity::doc;
|
||||||
use crate::identity::Id;
|
use crate::identity::doc::{DocError, Id};
|
||||||
|
use crate::identity::project::Project;
|
||||||
use crate::node;
|
use crate::node;
|
||||||
use crate::node::NodeId;
|
use crate::node::NodeId;
|
||||||
use crate::storage::git::transport::{self, remote};
|
use crate::storage::git::transport::{self, remote};
|
||||||
|
|
@ -29,11 +30,9 @@ pub fn peer_remote(peer: &NodeId) -> String {
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum InitError {
|
pub enum InitError {
|
||||||
#[error("doc: {0}")]
|
#[error("doc: {0}")]
|
||||||
Doc(#[from] identity::project::DocError),
|
Doc(#[from] DocError),
|
||||||
#[error("project: {0}")]
|
#[error("project: {0}")]
|
||||||
Project(#[from] storage::git::ProjectError),
|
Project(#[from] storage::git::ProjectError),
|
||||||
#[error("doc: {0}")]
|
|
||||||
DocVerification(#[from] identity::project::VerificationError),
|
|
||||||
#[error("git: {0}")]
|
#[error("git: {0}")]
|
||||||
Git(#[from] git2::Error),
|
Git(#[from] git2::Error),
|
||||||
#[error("i/o: {0}")]
|
#[error("i/o: {0}")]
|
||||||
|
|
@ -96,7 +95,7 @@ pub enum ForkError {
|
||||||
#[error("storage: {0}")]
|
#[error("storage: {0}")]
|
||||||
Storage(#[from] storage::Error),
|
Storage(#[from] storage::Error),
|
||||||
#[error("payload: {0}")]
|
#[error("payload: {0}")]
|
||||||
Payload(#[from] project::PayloadError),
|
Payload(#[from] doc::PayloadError),
|
||||||
#[error("project `{0}` was not found in storage")]
|
#[error("project `{0}` was not found in storage")]
|
||||||
NotFound(Id),
|
NotFound(Id),
|
||||||
#[error("project identity error: {0}")]
|
#[error("project identity error: {0}")]
|
||||||
|
|
@ -250,7 +249,7 @@ pub enum CheckoutError {
|
||||||
#[error("storage: {0}")]
|
#[error("storage: {0}")]
|
||||||
Storage(#[from] storage::Error),
|
Storage(#[from] storage::Error),
|
||||||
#[error("payload: {0}")]
|
#[error("payload: {0}")]
|
||||||
Payload(#[from] project::PayloadError),
|
Payload(#[from] doc::PayloadError),
|
||||||
#[error("project `{0}` was not found in storage")]
|
#[error("project `{0}` was not found in storage")]
|
||||||
NotFound(Id),
|
NotFound(Id),
|
||||||
#[error("project error: {0}")]
|
#[error("project error: {0}")]
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ use radicle_cob::{self as cob, change};
|
||||||
|
|
||||||
use crate::git;
|
use crate::git;
|
||||||
use crate::identity;
|
use crate::identity;
|
||||||
use crate::identity::project::{Identity, IdentityError, Project, VerificationError};
|
use crate::identity::{doc, Doc, Id};
|
||||||
use crate::identity::{Doc, Id};
|
use crate::identity::{Identity, IdentityError, Project};
|
||||||
use crate::storage::refs;
|
use crate::storage::refs;
|
||||||
use crate::storage::refs::{Refs, SignedRefs};
|
use crate::storage::refs::{Refs, SignedRefs};
|
||||||
use crate::storage::{
|
use crate::storage::{
|
||||||
|
|
@ -40,11 +40,9 @@ pub enum ProjectError {
|
||||||
#[error("storage error: {0}")]
|
#[error("storage error: {0}")]
|
||||||
Storage(#[from] Error),
|
Storage(#[from] Error),
|
||||||
#[error("identity document error: {0}")]
|
#[error("identity document error: {0}")]
|
||||||
Doc(#[from] identity::project::DocError),
|
Doc(#[from] doc::DocError),
|
||||||
#[error("payload error: {0}")]
|
#[error("payload error: {0}")]
|
||||||
Payload(#[from] identity::project::PayloadError),
|
Payload(#[from] doc::PayloadError),
|
||||||
#[error("verification error: {0}")]
|
|
||||||
Verification(#[from] VerificationError),
|
|
||||||
#[error("git: {0}")]
|
#[error("git: {0}")]
|
||||||
Git(#[from] git2::Error),
|
Git(#[from] git2::Error),
|
||||||
#[error("git: {0}")]
|
#[error("git: {0}")]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ use qcheck::Arbitrary;
|
||||||
|
|
||||||
use crate::collections::HashMap;
|
use crate::collections::HashMap;
|
||||||
use crate::git;
|
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;
|
||||||
use crate::storage::refs::{Refs, SignedRefs};
|
use crate::storage::refs::{Refs, SignedRefs};
|
||||||
use crate::test::storage::MockStorage;
|
use crate::test::storage::MockStorage;
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ use git_ref_format as fmt;
|
||||||
use radicle_git_ext as git_ext;
|
use radicle_git_ext as git_ext;
|
||||||
|
|
||||||
use crate::crypto::{Signer, Verified};
|
use crate::crypto::{Signer, Verified};
|
||||||
use crate::identity::project::Doc;
|
use crate::identity::doc::{Doc, Id};
|
||||||
use crate::identity::Id;
|
|
||||||
|
|
||||||
pub use crate::storage::*;
|
pub use crate::storage::*;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue