From de1958fab04a95f73b7d7dcc62d66775d0854fc1 Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Tue, 15 Oct 2024 12:26:51 +0100 Subject: [PATCH] radicle: refactor doc The aim of this change is to make the `Doc` type more safe to use by approaching the design via [Parse don't validate][[0]] approach. The problem with the previous approach was that all field were `pub` and thus a `Doc` could easily be mutated and serialized. Granted, the code that used the serialization would tend to verify the `Doc` first, however, this approach *ensures* that only a verified `Doc` can be serialized. It also meant that trying to add new data that would follow the parse approach would require more generic parameters on top of the existing `PhantomData` parameter, i.e. we need to do something like: `Doc -> Doc`. The new approach splits the type into two separate types: `RawDoc` and `Doc`. The former is allowed to be mutated at will, and uses types that are less strict. The latter is the valid type that can only be constructed by validating a `RawDoc` (or the `initial` constructor). The `Doc` type's fields can then only be accessed by read-only methods. Solves the problems above by only allowing mutations to `RawDoc`, as well as, new fields being added to `RawDoc` which are then validated via `RawDoc::verified`. [0]: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ --- radicle-cli/src/commands/checkout.rs | 8 +- radicle-cli/src/commands/clone.rs | 4 +- radicle-cli/src/commands/id.rs | 45 +- radicle-cli/src/commands/init.rs | 13 +- radicle-cli/src/commands/inspect.rs | 6 +- radicle-cli/src/commands/ls.rs | 6 +- radicle-cli/src/commands/publish.rs | 14 +- radicle-cli/src/commands/seed.rs | 2 +- radicle-cli/src/node.rs | 4 +- radicle-fetch/src/handle.rs | 7 +- radicle-fetch/src/stage.rs | 4 +- radicle-fetch/src/state.rs | 16 +- radicle-node/src/service.rs | 23 +- radicle-node/src/tests.rs | 36 +- radicle-node/src/worker.rs | 3 +- radicle-remote-helper/src/push.rs | 10 +- radicle/src/cob/identity.rs | 258 +++++++---- radicle/src/cob/issue.rs | 10 +- radicle/src/cob/patch.rs | 12 +- radicle/src/identity.rs | 2 +- radicle/src/identity/doc.rs | 642 ++++++++++++++++++++------- radicle/src/lib.rs | 2 +- radicle/src/rad.rs | 6 +- radicle/src/storage.rs | 16 +- radicle/src/storage/git.rs | 21 +- radicle/src/storage/refs.rs | 16 +- radicle/src/test/arbitrary.rs | 23 +- radicle/src/test/storage.rs | 10 +- 28 files changed, 834 insertions(+), 385 deletions(-) diff --git a/radicle-cli/src/commands/checkout.rs b/radicle-cli/src/commands/checkout.rs index 0554279f..1e77243e 100644 --- a/radicle-cli/src/commands/checkout.rs +++ b/radicle-cli/src/commands/checkout.rs @@ -84,11 +84,10 @@ fn execute(options: Options, profile: &Profile) -> anyhow::Result { let id = options.id; let storage = &profile.storage; let remote = options.remote.unwrap_or(profile.did()); - let doc: Doc<_> = storage + let doc = storage .repository(id)? .identity_doc() - .context("repository could not be found in local storage")? - .into(); + .context("repository could not be found in local storage")?; let payload = doc.project()?; let path = PathBuf::from(payload.name()); @@ -115,7 +114,8 @@ fn execute(options: Options, profile: &Profile) -> anyhow::Result { spinner.finish(); let remotes = doc - .delegates + .delegates() + .clone() .into_iter() .map(|did| *did) .filter(|id| id != profile.id()) diff --git a/radicle-cli/src/commands/clone.rs b/radicle-cli/src/commands/clone.rs index 7c2b9215..9f39a7a5 100644 --- a/radicle-cli/src/commands/clone.rs +++ b/radicle-cli/src/commands/clone.rs @@ -153,7 +153,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { &profile.storage, )?; let delegates = doc - .delegates + .delegates() .iter() .map(|d| **d) .filter(|id| id != profile.id()) @@ -240,7 +240,7 @@ pub fn clone( ( raw::Repository, storage::git::Repository, - Doc, + Doc, Project, ), CloneError, diff --git a/radicle-cli/src/commands/id.rs b/radicle-cli/src/commands/id.rs index ba8dcbd1..694ae23e 100644 --- a/radicle-cli/src/commands/id.rs +++ b/radicle-cli/src/commands/id.rs @@ -4,14 +4,12 @@ use std::{ffi::OsString, io}; use anyhow::{anyhow, Context}; -use nonempty::NonEmpty; use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId}; -use radicle::identity::{doc, Identity, Visibility}; -use radicle::prelude::{Did, Doc, RepoId, Signer}; +use radicle::identity::{doc, Doc, Identity, RawDoc, Visibility}; +use radicle::prelude::{Did, RepoId, Signer}; use radicle::storage::refs; use radicle::storage::{ReadRepository, ReadStorage as _, WriteRepository}; use radicle::{cob, Profile}; -use radicle_crypto::Verified; use radicle_surf::diff::Diff; use radicle_term::Element; use serde_json as json; @@ -389,7 +387,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { payload, } => { let proposal = { - let mut proposal = current.doc.clone(); + let mut proposal = current.doc.clone().edit(); proposal.threshold = threshold.unwrap_or(proposal.threshold); if !allow.is_disjoint(&disallow) { @@ -429,18 +427,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { proposal.visibility = Visibility::Public; } } - proposal.delegates = NonEmpty::from_vec( - proposal - .delegates - .into_iter() - .chain(delegates) - .filter(|d| !rescind.contains(d)) - .collect::>(), - ) - .ok_or(anyhow!( - "at lease one delegate must be present for the identity to be valid" - ))?; - + proposal.delegates = proposal + .delegates + .into_iter() + .chain(delegates) + .filter(|d| !rescind.contains(d)) + .collect::>(); if let Some(errs) = verify_delegates(&proposal, &repo)? { term::error(format!("failed to verify delegates for {rid}")); term::error(format!( @@ -475,6 +467,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { } proposal }; + let proposal = proposal.verified()?; if proposal == current.doc { if !options.quiet { term::print(term::format::italic( @@ -578,11 +571,7 @@ fn get<'a>( Ok(revision) } -fn print_meta( - revision: &Revision, - previous: &Doc, - profile: &Profile, -) -> anyhow::Result<()> { +fn print_meta(revision: &Revision, previous: &Doc, profile: &Profile) -> anyhow::Result<()> { let mut attrs = term::Table::<2, term::Label>::new(Default::default()); attrs.push([ @@ -630,7 +619,7 @@ fn print_meta( let accepted = revision.accepted().collect::>(); let rejected = revision.rejected().collect::>(); let unknown = previous - .delegates + .delegates() .iter() .filter(|id| !accepted.contains(id) && !rejected.contains(id)) .collect::>(); @@ -712,7 +701,7 @@ and description. fn update( title: Option, description: Option, - doc: Doc, + doc: Doc, current: &mut IdentityMut, signer: &G, ) -> anyhow::Result { @@ -734,14 +723,14 @@ fn print_diff( repo: &radicle::storage::git::Repository, ) -> anyhow::Result<()> { let previous = if let Some(previous) = previous { - let previous = Doc::::load_at(*previous, repo)?; + let previous = Doc::load_at(*previous, repo)?; let previous = serde_json::to_string_pretty(&previous.doc)?; Some(previous) } else { None }; - let current = Doc::::load_at(*current, repo)?; + let current = Doc::load_at(*current, repo)?; let current = serde_json::to_string_pretty(¤t.doc)?; let tmp = tempfile::tempdir()?; @@ -798,8 +787,8 @@ impl VerificationError { } } -fn verify_delegates( - proposal: &Doc, +fn verify_delegates( + proposal: &RawDoc, repo: &S, ) -> anyhow::Result>> where diff --git a/radicle-cli/src/commands/init.rs b/radicle-cli/src/commands/init.rs index 59113c19..6c903bc1 100644 --- a/radicle-cli/src/commands/init.rs +++ b/radicle-cli/src/commands/init.rs @@ -10,15 +10,14 @@ use std::str::FromStr; use anyhow::{anyhow, bail, Context as _}; use serde_json as json; -use radicle::crypto::{ssh, Verified}; +use radicle::crypto::ssh; use radicle::explorer::ExplorerUrl; use radicle::git::RefString; use radicle::identity::project::ProjectName; -use radicle::identity::{RepoId, Visibility}; +use radicle::identity::{RepoId, Doc, Visibility}; use radicle::node::events::UploadPack; use radicle::node::policy::Scope; use radicle::node::{Event, Handle, NodeId, DEFAULT_SUBSCRIBE_TIMEOUT}; -use radicle::prelude::Doc; use radicle::storage::ReadStorage as _; use radicle::{profile, Node}; @@ -302,7 +301,7 @@ pub fn init( if options.seed { profile.seed(rid, options.scope, &mut node)?; - if doc.visibility.is_public() { + if doc.is_public() { profile.add_inventory(rid, &mut node)?; } } @@ -529,11 +528,11 @@ fn sync( pub fn announce( rid: RepoId, - doc: Doc, + doc: Doc, node: &mut Node, config: &profile::Config, ) -> anyhow::Result<()> { - if doc.visibility.is_public() { + if doc.is_public() { match sync(rid, node, config) { Ok(SyncResult::Synced { result: Some(url), .. @@ -597,7 +596,7 @@ pub fn announce( } else { term::info!( "You have created a {} repository.", - term::format::visibility(&doc.visibility) + term::format::visibility(doc.visibility()) ); term::info!( "This repository will only be visible to you, \ diff --git a/radicle-cli/src/commands/inspect.rs b/radicle-cli/src/commands/inspect.rs index dabd81e6..84ba3cbd 100644 --- a/radicle-cli/src/commands/inspect.rs +++ b/radicle-cli/src/commands/inspect.rs @@ -152,7 +152,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { } Target::Payload => { let (_, doc) = repo(rid, storage)?; - json::to_pretty(&doc.payload, Path::new("radicle.json"))?.print(); + json::to_pretty(&doc.payload(), Path::new("radicle.json"))?.print(); } Target::Identity => { let (_, doc) = repo(rid, storage)?; @@ -195,7 +195,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { Target::Delegates => { let (_, doc) = repo(rid, storage)?; let aliases = profile.aliases(); - for did in &doc.delegates { + for did in doc.delegates().iter() { if let Some(alias) = aliases.alias(did) { println!( "{} {}", @@ -209,7 +209,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { } Target::Visibility => { let (_, doc) = repo(rid, storage)?; - println!("{}", term::format::visibility(&doc.visibility)); + println!("{}", term::format::visibility(doc.visibility())); } Target::History => { let (repo, _) = repo(rid, storage)?; diff --git a/radicle-cli/src/commands/ls.rs b/radicle-cli/src/commands/ls.rs index c26cd840..8d8a2d3f 100644 --- a/radicle-cli/src/commands/ls.rs +++ b/radicle-cli/src/commands/ls.rs @@ -105,10 +105,10 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { .. } in repos { - if doc.visibility.is_public() && options.private && !options.public { + if doc.is_public() && options.private && !options.public { continue; } - if !doc.visibility.is_public() && !options.private && options.public { + if !doc.is_public() && !options.private && options.public { continue; } if refs.is_none() && !options.all && !options.seeded { @@ -135,7 +135,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { term::format::bold(proj.name().to_owned()), term::format::tertiary(rid.urn()), if seeded { - term::format::visibility(&doc.visibility).into() + term::format::visibility(doc.visibility()).into() } else { term::format::dim("local").into() }, diff --git a/radicle-cli/src/commands/publish.rs b/radicle-cli/src/commands/publish.rs index 01d81d3b..146f80b9 100644 --- a/radicle-cli/src/commands/publish.rs +++ b/radicle-cli/src/commands/publish.rs @@ -77,19 +77,19 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { let repo = profile.storage.repository_mut(rid)?; let mut identity = Identity::load_mut(&repo)?; - let mut doc = identity.doc().clone(); + let doc = identity.doc(); - if doc.visibility.is_public() { + if doc.is_public() { return Err(Error::WithHint { err: anyhow!("repository is already public"), hint: "to announce the repository to the network, run `rad sync --inventory`", } .into()); } - if !doc.is_delegate(profile.id()) { + if !doc.is_delegate(&profile.id().into()) { return Err(anyhow!("only the repository delegate can publish it")); } - if doc.delegates.len() > 1 { + if doc.delegates().len() > 1 { return Err(Error::WithHint { err: anyhow!( "only repositories with a single delegate can be published with this command" @@ -101,7 +101,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { let signer = profile.signer()?; // Update identity document. - doc.visibility = Visibility::Public; + let doc = doc.clone().with_edits(|doc| { + doc.visibility = Visibility::Public; + })?; identity.update("Publish repository", "", &doc, &signer)?; repo.sign_refs(&signer)?; @@ -123,7 +125,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { term::success!( "Repository is now {}", - term::format::visibility(&doc.visibility) + term::format::visibility(doc.visibility()) ); if !node.is_running() { diff --git a/radicle-cli/src/commands/seed.rs b/radicle-cli/src/commands/seed.rs index 6c8b6c4f..c4033326 100644 --- a/radicle-cli/src/commands/seed.rs +++ b/radicle-cli/src/commands/seed.rs @@ -169,7 +169,7 @@ pub fn update( let outcome = if updated { "updated" } else { "exists" }; if let Ok(repo) = profile.storage.repository(rid) { - if repo.identity_doc()?.visibility.is_public() { + if repo.identity_doc()?.is_public() { profile.add_inventory(rid, node)?; term::success!("Inventory updated with {}", term::format::tertiary(rid)); } diff --git a/radicle-cli/src/node.rs b/radicle-cli/src/node.rs index 3c4ed4fd..3bd4f7ad 100644 --- a/radicle-cli/src/node.rs +++ b/radicle-cli/src/node.rs @@ -180,7 +180,7 @@ fn announce_( let rid = repo.id(); let doc = repo.identity_doc()?; let mut settings = settings.with_profile(profile); - let unsynced: Vec<_> = if doc.visibility.is_public() { + let unsynced: Vec<_> = if doc.is_public() { // All seeds. let all = node.seeds(rid)?; if all.is_empty() { @@ -218,7 +218,7 @@ fn announce_( } else { node.sessions()? .into_iter() - .filter(|s| s.state.is_connected() && doc.is_visible_to(&s.nid)) + .filter(|s| s.state.is_connected() && doc.is_visible_to(&s.nid.into())) .map(|s| s.nid) .collect() }; diff --git a/radicle-fetch/src/handle.rs b/radicle-fetch/src/handle.rs index ebc86d81..5916af7d 100644 --- a/radicle-fetch/src/handle.rs +++ b/radicle-fetch/src/handle.rs @@ -2,10 +2,9 @@ use std::sync::atomic::{self, AtomicBool}; use std::sync::Arc; use bstr::BString; -use radicle::crypto::{PublicKey, Verified}; +use radicle::crypto::PublicKey; use radicle::git::Oid; -use radicle::identity::DocError; -use radicle::prelude::Doc; +use radicle::identity::{DocError, Doc}; use radicle::storage::git::Repository; use radicle::storage::ReadRepository; @@ -74,7 +73,7 @@ impl Handle { self.interrupt.store(true, atomic::Ordering::Relaxed); } - pub fn verified(&self, head: Oid) -> Result, DocError> { + pub fn verified(&self, head: Oid) -> Result { Ok(self.repo.identity_doc_at(head)?.doc) } diff --git a/radicle-fetch/src/stage.rs b/radicle-fetch/src/stage.rs index d41fc12f..be242205 100644 --- a/radicle-fetch/src/stage.rs +++ b/radicle-fetch/src/stage.rs @@ -207,8 +207,8 @@ impl ProtocolStage for CanonicalId { remote: self.remote, err: Box::new(err), })?; - if verified.delegates.contains(&self.remote.into()) { - let is_delegate = |remote: &PublicKey| verified.is_delegate(remote); + if verified.is_delegate(&self.remote.into()) { + let is_delegate = |remote: &PublicKey| verified.is_delegate(&remote.into()); Ok(Updates::build( refs.iter() .filter_map(|r| r.as_special_ref_update(is_delegate)), diff --git a/radicle-fetch/src/state.rs b/radicle-fetch/src/state.rs index 489cbb83..86e5f39d 100644 --- a/radicle-fetch/src/state.rs +++ b/radicle-fetch/src/state.rs @@ -4,7 +4,7 @@ use std::time::Instant; use gix_protocol::handshake; use radicle::crypto::PublicKey; use radicle::git::{Oid, Qualified}; -use radicle::identity::{Did, Doc, DocError}; +use radicle::identity::{Did, DocError, Doc}; use radicle::prelude::Verified; use radicle::storage; @@ -389,11 +389,11 @@ impl FetchState { .canonical()? .ok_or(error::Protocol::MissingRadId)?; - let is_delegate = anchor.delegates.contains(&Did::from(handle.local())); + let is_delegate = anchor.is_delegate(&Did::from(handle.local())); // TODO: not sure we should allow to block *any* peer from the // delegate set. We could end up ignoring delegates. let delegates = anchor - .delegates + .delegates() .iter() .filter(|id| !handle.is_blocked(id)) .map(|did| PublicKey::from(*did)) @@ -404,9 +404,9 @@ impl FetchState { // The local peer does not need to count towards the threshold // since they must be valid already. let threshold = if is_delegate { - anchor.threshold - 1 + anchor.threshold() - 1 } else { - anchor.threshold + anchor.threshold() }; let signed_refs = self.run_special_refs( handle, @@ -637,11 +637,11 @@ impl<'a, S> Cached<'a, S> { self.state.canonical_rad_id().copied() } - pub fn verified(&self, head: Oid) -> Result, DocError> { + pub fn verified(&self, head: Oid) -> Result { self.handle.verified(head) } - pub fn canonical(&self) -> Result>, error::Canonical> { + pub fn canonical(&self) -> Result, error::Canonical> { let tip = self.refname_to_id(refs::REFS_RAD_ID.clone())?; let cached_tip = self.canonical_rad_id(); @@ -736,7 +736,7 @@ impl<'a, S> ValidateRepository for Cached<'a, S> { /// N.b. if the repository does not have the project payload or a /// deserialization error occurs, then this will return `None`. fn validate_project_default_branch( - anchor: &Doc, + anchor: &Doc, sigrefs: &SignedRefs, ) -> Option { let proj = anchor.project().ok()?; diff --git a/radicle-node/src/service.rs b/radicle-node/src/service.rs index 0b2bf7ff..dde23a78 100644 --- a/radicle-node/src/service.rs +++ b/radicle-node/src/service.rs @@ -22,6 +22,7 @@ use localtime::{LocalDuration, LocalTime}; use log::*; use nonempty::NonEmpty; +use radicle::identity::Doc; use radicle::node; use radicle::node::address; use radicle::node::address::Store as _; @@ -36,8 +37,8 @@ use radicle::storage::refs::SIGREFS_BRANCH; use radicle::storage::RepositoryError; use radicle_fetch::policy::SeedingPolicy; -use crate::crypto::{Signer, Verified}; -use crate::identity::{Doc, RepoId}; +use crate::crypto::Signer; +use crate::identity::RepoId; use crate::node::routing; use crate::node::routing::InsertResult; use crate::node::{ @@ -678,7 +679,7 @@ where continue; } // Add public repositories to inventory. - if repo.doc.visibility.is_public() { + if repo.doc.is_public() { inventory.insert(rid); } else { private.insert(rid); @@ -1168,7 +1169,7 @@ where // Announce our new inventory if this fetch was a full clone. // Only update and announce inventory for public repositories. - if clone && doc.visibility.is_public() { + if clone && doc.is_public() { debug!(target: "service", "Updating and announcing inventory for cloned repository {rid}.."); if let Err(e) = self.add_inventory(rid) { @@ -2128,7 +2129,7 @@ where } /// Announce our own refs for the given repo. - fn announce_own_refs(&mut self, rid: RepoId, doc: Doc) -> Result, Error> { + fn announce_own_refs(&mut self, rid: RepoId, doc: Doc) -> Result, Error> { let (refs, timestamp) = self.announce_refs(rid, doc, [self.node_id()])?; // Update refs database with our signed refs branches. @@ -2161,7 +2162,7 @@ where fn announce_refs( &mut self, rid: RepoId, - doc: Doc, + doc: Doc, remotes: impl IntoIterator, ) -> Result<(Vec, Timestamp), Error> { let (ann, refs) = self.refs_announcement_for(rid, remotes)?; @@ -2192,7 +2193,7 @@ where ann, peers.filter(|p| { // Only announce to peers who are allowed to view this repo. - doc.is_visible_to(&p.id) + doc.is_visible_to(&p.id.into()) }), self.db.gossip_mut(), ); @@ -2341,7 +2342,7 @@ where .get(rid) .ok() .flatten() - .map(|doc| doc.is_visible_to(id)) + .map(|doc| doc.is_visible_to(&(*id).into())) .unwrap_or(false) } else { // Announcement doesn't concern a specific repository, let it through. @@ -2634,7 +2635,7 @@ pub trait ServiceState { /// Get event emitter. fn emitter(&self) -> &Emitter; /// Get a repository from storage. - fn get(&self, rid: RepoId) -> Result>, RepositoryError>; + fn get(&self, rid: RepoId) -> Result, RepositoryError>; /// Get the clock. fn clock(&self) -> &LocalTime; /// Get the clock mutably. @@ -2675,7 +2676,7 @@ where &self.emitter } - fn get(&self, rid: RepoId) -> Result>, RepositoryError> { + fn get(&self, rid: RepoId) -> Result, RepositoryError> { self.storage.get(rid) } @@ -2751,7 +2752,7 @@ impl fmt::Display for DisconnectReason { #[derive(Debug)] pub struct Lookup { /// Whether the project was found locally or not. - pub local: Option>, + pub local: Option, /// A list of remote peers on which the project is known to exist. pub remote: Vec, } diff --git a/radicle-node/src/tests.rs b/radicle-node/src/tests.rs index 3e4cb320..5af18379 100644 --- a/radicle-node/src/tests.rs +++ b/radicle-node/src/tests.rs @@ -712,7 +712,13 @@ fn test_refs_announcement_relay_public() { // Pretend Alice cloned Bob's repos. let repos = gen::<[MockRepository; 3]>(1); for (i, mut repo) in repos.into_iter().enumerate() { - repo.doc.doc.visibility = Visibility::Public; // Public repos are always gossiped. + repo.doc.doc = repo + .doc + .doc + .with_edits(|doc| { + doc.visibility = Visibility::Public; // Public repos are always gossiped. + }) + .unwrap(); alice.storage_mut().repos.insert(bob_inv[i], repo); } assert_matches!( @@ -784,14 +790,32 @@ fn test_refs_announcement_relay_private() { alice.receive(eve.id(), Message::Subscribe(Subscribe::all())); // The first repo is not visible to Eve. - let mut repo1 = gen::(1); - repo1.doc.doc.visibility = Visibility::Private { allow: [].into() }; + let repo1 = { + let mut repo = gen::(1); + repo.doc.doc = repo + .doc + .doc + .with_edits(|doc| { + doc.visibility = Visibility::Private { allow: [].into() }; + }) + .unwrap(); + repo + }; alice.storage_mut().repos.insert(bob_inv[0], repo1); // The second repo is visible to Eve. - let mut repo2 = gen::(1); - repo2.doc.doc.visibility = Visibility::Private { - allow: [eve.id.into()].into(), + let repo2 = { + let mut repo = gen::(1); + repo.doc.doc = repo + .doc + .doc + .with_edits(|doc| { + doc.visibility = Visibility::Private { + allow: [eve.id.into()].into(), + }; + }) + .unwrap(); + repo }; alice.storage_mut().repos.insert(bob_inv[1], repo2); alice.elapse(service::GOSSIP_INTERVAL); diff --git a/radicle-node/src/worker.rs b/radicle-node/src/worker.rs index f84e02e5..bc5277f3 100644 --- a/radicle-node/src/worker.rs +++ b/radicle-node/src/worker.rs @@ -287,7 +287,8 @@ impl Worker { } let repo = self.storage.repository(rid)?; let doc = repo.identity_doc()?; - if !doc.is_visible_to(&remote) { + + if !doc.is_visible_to(&remote.into()) { Err(UploadError::Unauthorized(remote, rid)) } else { Ok(()) diff --git a/radicle-remote-helper/src/push.rs b/radicle-remote-helper/src/push.rs index 6bc65723..4bfd2355 100644 --- a/radicle-remote-helper/src/push.rs +++ b/radicle-remote-helper/src/push.rs @@ -266,8 +266,12 @@ pub fn run( if dst == canonical_ref && delegates.contains(&me) && delegates.len() > 1 { let head = working.find_reference(src.as_str())?; let head = head.peel_to_commit()?.id(); - let mut canonical = - Canonical::default_branch(stored, &project, &identity.delegates)?; + + let mut canonical = Canonical::default_branch( + stored, + &project, + identity.delegates().as_ref(), + )?; let converges = canonical::converges( canonical .tips() @@ -279,7 +283,7 @@ pub fn run( canonical.modify_vote(me, head.into()); } - match canonical.quorum(identity.threshold, &working) { + match canonical.quorum(identity.threshold(), &working) { Ok(canonical_oid) => { // Canonical head is an ancestor of head. let is_ff = head == *canonical_oid diff --git a/radicle/src/cob/identity.rs b/radicle/src/cob/identity.rs index 9a166df0..ec44f282 100644 --- a/radicle/src/cob/identity.rs +++ b/radicle/src/cob/identity.rs @@ -4,12 +4,13 @@ use std::{fmt, ops::Deref, str::FromStr}; use crypto::{PublicKey, Signature}; use once_cell::sync::Lazy; use radicle_cob::{Embed, ObjectId, TypeName}; -use radicle_crypto::{Signer, Verified}; +use radicle_crypto::Signer; use radicle_git_ext as git_ext; use radicle_git_ext::Oid; use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::identity::doc::Doc; use crate::{ cob, cob::{ @@ -18,7 +19,7 @@ use crate::{ ActorId, Timestamp, Uri, }, identity::{ - doc::{Doc, DocError, RepoId}, + doc::{DocError, RepoId}, Did, }, storage::{ReadRepository, RepositoryError, WriteRepository}, @@ -171,7 +172,7 @@ impl Identity { root, current: root, heads: revision - .delegates + .delegates() .iter() .copied() .map(|did| (did, root)) @@ -182,7 +183,7 @@ impl Identity { } pub fn initialize<'a, R: WriteRepository + cob::Store, G: Signer>( - doc: &Doc, + doc: &Doc, store: &'a R, signer: &G, ) -> Result, cob::store::Error> { @@ -249,7 +250,7 @@ impl Identity { } /// The current document. - pub fn doc(&self) -> &Doc { + pub fn doc(&self) -> &Doc { &self.current().doc } @@ -320,7 +321,7 @@ impl store::Cob for Identity { "the first operation must contain only one action", )); } - let root = Doc::::load_at(op.id, repo)?; + let root = Doc::load_at(op.id, repo)?; if root.blob != blob { return Err(ApplyError::Init("invalid object id specified in revision")); } @@ -331,7 +332,7 @@ impl store::Cob for Identity { } assert_eq!(root.commit, op.id); - let founder = root.delegates.first(); + let founder = root.delegates().first(); if founder.as_key() != &op.author { return Err(ApplyError::Init("delegate does not match committer")); } @@ -411,7 +412,7 @@ impl Identity { ) -> Result<(), ApplyError> { let current = self.current().clone(); - if !current.is_delegate(&author) { + if !current.is_delegate(&author.into()) { return Err(ApplyError::UnexpectedState); } match action { @@ -674,7 +675,7 @@ pub struct Revision { /// Author of this proposed revision. pub author: Author, /// New [`Doc`] that will replace `previous`' document. - pub doc: Doc, + pub doc: Doc, /// Physical timestamp of this proposal revision. pub timestamp: Timestamp, /// Parent revision. @@ -685,7 +686,7 @@ pub struct Revision { } impl std::ops::Deref for Revision { - type Target = Doc; + type Target = Doc; fn deref(&self) -> &Self::Target { &self.doc @@ -736,7 +737,7 @@ impl Revision { description: String, author: Author, blob: Oid, - doc: Doc, + doc: Doc, state: State, signature: Signature, parent: Option, @@ -788,7 +789,7 @@ impl Revision { // Mark as rejected if it's impossible for this revision to be accepted // with the current delegate set. Note that if the delegate set changes, // this proposal will be marked as `stale` anyway. - if self.is_active() && self.rejected().count() > self.delegates.len() - self.majority() { + if self.is_active() && self.rejected().count() > self.delegates().len() - self.majority() { self.state = State::Rejected; } Ok(()) @@ -834,7 +835,7 @@ impl store::Transaction { &mut self, title: impl ToString, description: impl ToString, - doc: &Doc, + doc: &Doc, parent: Option, repo: &R, signer: &G, @@ -914,7 +915,7 @@ where &mut self, title: impl ToString, description: impl ToString, - doc: &Doc, + doc: &Doc, signer: &G, ) -> Result { let parent = self.current; @@ -1045,7 +1046,7 @@ mod test { let bob = MockSigner::default(); let signer = &node.signer; let mut identity = Identity::load_mut(&*repo).unwrap(); - let mut doc = identity.doc().clone(); + let mut doc = identity.doc().clone().edit(); let title = "Identity update"; let description = ""; let r0 = identity.current; @@ -1054,33 +1055,38 @@ mod test { assert!(identity.current().is_accepted()); // Using an identical document to the current one fails. identity - .update(title, description, &doc, signer) + .update(title, description, &doc.clone().verified().unwrap(), signer) .unwrap_err(); assert_eq!(identity.current, r0); // Change threshold to `2`, even though there's only one delegate. This should // fail as it makes the master branch immutable. doc.threshold = 2; - identity - .update(title, description, &doc, signer) - .unwrap_err(); - assert_eq!(identity.current, r0); + assert!(doc.clone().verified().is_err()); + // Let's add another delegate. - doc.delegate(bob.public_key()); + doc.delegate(bob.public_key().into()); // The update should go through now. - let r1 = identity.update(title, description, &doc, signer).unwrap(); + let r1 = identity + .update(title, description, &doc.clone().verified().unwrap(), signer) + .unwrap(); assert!(identity.revision(&r1).unwrap().is_accepted()); assert_eq!(identity.current, r1); // With two delegates now, we need two signatures for any update to go through. // So this next update shouldn't be accepted as canonical until the second delegate // signs it. doc.visibility = Visibility::private([]); - let r2 = identity.update(title, description, &doc, signer).unwrap(); + let r2 = identity + .update(title, description, &doc.clone().verified().unwrap(), signer) + .unwrap(); // R1 is still the head. assert_eq!(identity.current, r1); assert_eq!(identity.revision(&r2).unwrap().state, State::Active); assert_eq!(repo.canonical_identity_head().unwrap(), r1); - assert_eq!(repo.identity_doc().unwrap().visibility, Visibility::Public); + assert_eq!( + repo.identity_doc().unwrap().visibility(), + &Visibility::Public + ); // Now let's add a signature on R2 from Bob. identity.accept(&r2, &bob).unwrap(); @@ -1089,8 +1095,8 @@ mod test { assert_eq!(identity.revision(&r2).unwrap().state, State::Accepted); assert_eq!(repo.canonical_identity_head().unwrap(), r2); assert_eq!( - repo.canonical_identity_doc().unwrap().visibility, - Visibility::private([]) + repo.canonical_identity_doc().unwrap().visibility(), + &Visibility::private([]) ); } @@ -1101,18 +1107,25 @@ mod test { let eve = MockSigner::default(); let signer = &node.signer; let mut identity = Identity::load_mut(&*repo).unwrap(); - let mut doc = identity.doc().clone(); + let mut doc = identity.doc().clone().edit(); let title = "Identity update"; let description = ""; // Let's add another delegate. - doc.delegate(bob.public_key()); - let r1 = identity.update(title, description, &doc, signer).unwrap(); + doc.delegate(bob.public_key().into()); + let r1 = identity + .update(title, description, &doc.clone().verified().unwrap(), signer) + .unwrap(); assert_eq!(identity.current, r1); doc.visibility = Visibility::private([]); let r2 = identity - .update("Make private", description, &doc, &node.signer) + .update( + "Make private", + description, + &doc.clone().verified().unwrap(), + &node.signer, + ) .unwrap(); // 1/2 rejected means that we can never reach the required 2/2 votes. @@ -1121,16 +1134,26 @@ mod test { assert_eq!(r2.state, State::Rejected); // Now let's add another delegate. - doc.delegate(eve.public_key()); + doc.delegate(eve.public_key().into()); let r3 = identity - .update("Add Eve", description, &doc, &node.signer) + .update( + "Add Eve", + description, + &doc.clone().verified().unwrap(), + &node.signer, + ) .unwrap(); let _ = identity.accept(&r3, &bob).unwrap(); assert_eq!(identity.current, r3); doc.visibility = Visibility::Public; let r3 = identity - .update("Make public", description, &doc, &node.signer) + .update( + "Make public", + description, + &doc.verified().unwrap(), + &node.signer, + ) .unwrap(); // 1/3 rejected means that we can still reach the 2/3 required votes. @@ -1151,27 +1174,42 @@ mod test { let bob = &network.bob; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); - let mut alice_doc = alice_identity.doc().clone(); + let mut alice_doc = alice_identity.doc().clone().edit(); - alice_doc.delegate(bob.signer.public_key()); + alice_doc.delegate(bob.signer.public_key().into()); let a1 = alice_identity - .update("Add Bob", "", &alice_doc, &alice.signer) + .update( + "Add Bob", + "", + &alice_doc.clone().verified().unwrap(), + &alice.signer, + ) .unwrap(); bob.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap(); let bob_doc = bob_identity.doc().clone(); - assert!(bob_doc.is_delegate(bob.signer.public_key())); + assert!(bob_doc.is_delegate(&bob.signer.public_key().into())); // Alice changes the document without making Bob aware. alice_doc.visibility = Visibility::private([]); let a2 = alice_identity - .update("Change visibility", "", &alice_doc, &alice.signer) + .update( + "Change visibility", + "", + &alice_doc.clone().clone().verified().unwrap(), + &alice.signer, + ) .unwrap(); // Bob makes the same change without knowing Alice already did. let b1 = bob_identity - .update("Make private", "", &alice_doc, &bob.signer) + .update( + "Make private", + "", + &alice_doc.verified().unwrap(), + &bob.signer, + ) .unwrap(); // Bob gets Alice's data. @@ -1203,17 +1241,27 @@ mod test { let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); - let mut alice_doc = alice_identity.doc().clone(); + let mut alice_doc = alice_identity.doc().clone().edit(); - alice_doc.delegate(bob.signer.public_key()); + alice_doc.delegate(bob.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity - .update("Add Bob", "Eh.", &alice_doc, &alice.signer) + .update( + "Add Bob", + "Eh.", + &alice_doc.clone().clone().verified().unwrap(), + &alice.signer, + ) .unwrap(); alice_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let a2 = alice_identity - .update("Change visibility", "Eh.", &alice_doc, &alice.signer) + .update( + "Change visibility", + "Eh.", + &alice_doc.verified().unwrap(), + &alice.signer, + ) .unwrap(); bob.repo.fetch(alice); @@ -1242,18 +1290,28 @@ mod test { let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); - let mut alice_doc = alice_identity.doc().clone(); + let mut alice_doc = alice_identity.doc().clone().edit(); - alice_doc.delegate(bob.signer.public_key()); - alice_doc.delegate(eve.signer.public_key()); + alice_doc.delegate(bob.signer.public_key().into()); + alice_doc.delegate(eve.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity // Change description to change traversal order. - .update("Add Bob and Eve", "Eh#!", &alice_doc, &alice.signer) + .update( + "Add Bob and Eve", + "Eh#!", + &alice_doc.clone().verified().unwrap(), + &alice.signer, + ) .unwrap(); - alice_doc.rescind(eve.signer.public_key()).unwrap(); + alice_doc.rescind(&eve.signer.public_key().into()).unwrap(); let a2 = alice_identity - .update("Remove Eve", "", &alice_doc, &alice.signer) + .update( + "Remove Eve", + "", + &alice_doc.verified().unwrap(), + &alice.signer, + ) .unwrap(); bob.repo.fetch(eve); @@ -1265,10 +1323,15 @@ mod test { assert_eq!(bob_identity.current, a2); let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap(); - let mut eve_doc = eve_identity.doc().clone(); + let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let e1 = eve_identity - .update("Change visibility", "", &eve_doc, &eve.signer) + .update( + "Change visibility", + "", + &eve_doc.verified().unwrap(), + &eve.signer, + ) .unwrap(); // Eve's revision is active. assert!(eve_identity.revision(&e1).unwrap().is_active()); @@ -1288,7 +1351,7 @@ mod test { // her revision is no longer valid. assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1]); assert_eq!(eve_identity.revision(&e1), None); - assert!(!eve_identity.is_delegate(eve.signer.public_key())); + assert!(!eve_identity.is_delegate(&eve.signer.public_key().into())); } #[test] @@ -1299,18 +1362,28 @@ mod test { let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); - let mut alice_doc = alice_identity.doc().clone(); + let mut alice_doc = alice_identity.doc().clone().edit(); - alice_doc.delegate(bob.signer.public_key()); - alice_doc.delegate(eve.signer.public_key()); + alice_doc.delegate(bob.signer.public_key().into()); + alice_doc.delegate(eve.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity - .update("Add Bob and Eve", "Eh!#", &alice_doc, &alice.signer) + .update( + "Add Bob and Eve", + "Eh!#", + &alice_doc.clone().verified().unwrap(), + &alice.signer, + ) .unwrap(); alice_doc.visibility = Visibility::private([]); let a2 = alice_identity - .update("Change visibility", "", &alice_doc, &alice.signer) + .update( + "Change visibility", + "", + &alice_doc.verified().unwrap(), + &alice.signer, + ) .unwrap(); bob.repo.fetch(eve); @@ -1327,10 +1400,15 @@ mod test { assert!(eve_identity.revision(&a2).unwrap().is_active()); // Then she submits a new revision. - let mut eve_doc = eve_identity.doc().clone(); + let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let e2 = eve_identity - .update("Change visibility", "", &eve_doc, &eve.signer) + .update( + "Change visibility", + "", + &eve_doc.verified().unwrap(), + &eve.signer, + ) .unwrap(); assert!(eve_identity.revision(&e2).unwrap().is_active()); @@ -1368,23 +1446,28 @@ mod test { let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); - let mut alice_doc = alice_identity.doc().clone(); + let mut alice_doc = alice_identity.doc().clone().edit(); alice.repo.fetch(bob); alice.repo.fetch(eve); - alice_doc.delegate(bob.signer.public_key()); - alice_doc.delegate(eve.signer.public_key()); + alice_doc.delegate(bob.signer.public_key().into()); + alice_doc.delegate(eve.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity - .update("Add Bob and Eve", "", &alice_doc, &alice.signer) + .update( + "Add Bob and Eve", + "", + &alice_doc.verified().unwrap(), + &alice.signer, + ) .unwrap(); bob.repo.fetch(alice); eve.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap(); - let mut bob_doc = bob_identity.doc().clone(); - assert!(bob_doc.is_delegate(bob.signer.public_key())); + let mut bob_doc = bob_identity.doc().clone().edit(); + assert!(bob_doc.is_delegate(&bob.signer.public_key().into())); // a2 e1 // | / @@ -1397,17 +1480,27 @@ mod test { // Bob and Alice change the document visibility. Eve is not aware. bob_doc.visibility = Visibility::private([]); let b1 = bob_identity - .update("Change visibility #1", "", &bob_doc, &bob.signer) + .update( + "Change visibility #1", + "", + &bob_doc.verified().unwrap(), + &bob.signer, + ) .unwrap(); alice.repo.fetch(bob); eve.repo.fetch(bob); // In the meantime, Eve does the same thing on her side. let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap(); - let mut eve_doc = eve_identity.doc().clone(); + let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([]); let e1 = eve_identity - .update("Change visibility #2", "Woops", &eve_doc, &eve.signer) + .update( + "Change visibility #2", + "Woops", + &eve_doc.verified().unwrap(), + &eve.signer, + ) .unwrap(); assert_eq!(eve_identity.revisions().count(), 4); assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Active); @@ -1441,27 +1534,37 @@ mod test { let repo = storage.repository(id).unwrap(); let mut identity = Identity::load_mut(&repo).unwrap(); - let mut doc = identity.doc().clone(); + let doc = identity.doc().clone(); let prj = doc.project().unwrap(); + let mut doc = doc.edit(); // Make a change to the description and sign it. let desc = prj.description().to_owned() + "!"; let prj = prj.update(None, desc, None).unwrap(); doc.payload.insert(PayloadId::project(), prj.clone().into()); identity - .update("Update description", "", &doc, &alice) + .update( + "Update description", + "", + &doc.clone().verified().unwrap(), + &alice, + ) .unwrap(); // Add Bob as a delegate, and sign it. - doc.delegate(bob.public_key()); + doc.delegate(bob.public_key().into()); doc.threshold = 2; - identity.update("Add bob", "", &doc, &alice).unwrap(); + identity + .update("Add bob", "", &doc.clone().verified().unwrap(), &alice) + .unwrap(); // Add Eve as a delegate. - doc.delegate(eve.public_key()); + doc.delegate(eve.public_key().into()); // Update with both Bob and Alice's signature. - let revision = identity.update("Add eve", "", &doc, &alice).unwrap(); + let revision = identity + .update("Add eve", "", &doc.clone().verified().unwrap(), &alice) + .unwrap(); identity.accept(&revision, &bob).unwrap(); // Update description again with signatures by Eve and Bob. @@ -1470,7 +1573,12 @@ mod test { doc.payload.insert(PayloadId::project(), prj.into()); let revision = identity - .update("Update description again", "Bob's repository", &doc, &bob) + .update( + "Update description again", + "Bob's repository", + &doc.verified().unwrap(), + &bob, + ) .unwrap(); identity.accept(&revision, &eve).unwrap(); diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs index 088cfd3a..a8ac8250 100644 --- a/radicle/src/cob/issue.rs +++ b/radicle/src/cob/issue.rs @@ -16,8 +16,8 @@ use crate::cob::thread; use crate::cob::thread::{Comment, CommentId, Thread}; use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName}; use crate::crypto::Signer; -use crate::identity::doc::{Doc, DocError}; -use crate::prelude::{Did, ReadRepository, RepoId, Verified}; +use crate::identity::doc::DocError; +use crate::prelude::{Did, Doc, ReadRepository, RepoId}; use crate::storage::{HasRepoId, RepositoryError, WriteRepository}; pub use cache::Cache; @@ -325,9 +325,9 @@ impl Issue { &self, action: &Action, actor: &ActorId, - doc: &Doc, + doc: &Doc, ) -> Result { - if doc.is_delegate(actor) { + if doc.is_delegate(&actor.into()) { // A delegate is authorized to do all actions. return Ok(Authorization::Allow); } @@ -388,7 +388,7 @@ impl Issue { author: ActorId, timestamp: Timestamp, _concurrent: &[&cob::Entry], - _doc: &Doc, + _doc: &Doc, _repo: &R, ) -> Result<(), Error> { match action { diff --git a/radicle/src/cob/patch.rs b/radicle/src/cob/patch.rs index e81fff55..8755dc89 100644 --- a/radicle/src/cob/patch.rs +++ b/radicle/src/cob/patch.rs @@ -630,9 +630,9 @@ impl Patch { &self, action: &Action, actor: &ActorId, - doc: &Doc, + doc: &Doc, ) -> Result { - if doc.is_delegate(actor) { + if doc.is_delegate(&actor.into()) { // A delegate is authorized to do all actions. return Ok(Authorization::Allow); } @@ -745,7 +745,7 @@ impl Patch { author: ActorId, timestamp: Timestamp, _concurrent: &[&cob::Entry], - identity: &Doc, + identity: &Doc, repo: &R, ) -> Result<(), Error> { match action { @@ -1046,7 +1046,7 @@ impl Patch { }, ); // Discard revisions that weren't merged by a threshold of delegates. - merges.retain(|_, count| *count >= identity.threshold); + merges.retain(|_, count| *count >= identity.threshold()); match merges.into_keys().collect::>().as_slice() { [] => { @@ -2970,9 +2970,9 @@ mod test { let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap(); let mut alice = Actor::new(MockSigner::default()); let rid = gen::(1); - let doc = Doc::new( + let doc = RawDoc::new( gen::(1), - nonempty::NonEmpty::new(alice.did()), + vec![alice.did()], 1, identity::Visibility::Public, ) diff --git a/radicle/src/identity.rs b/radicle/src/identity.rs index 64f082f0..620f16c7 100644 --- a/radicle/src/identity.rs +++ b/radicle/src/identity.rs @@ -5,7 +5,7 @@ pub mod project; pub use crypto::PublicKey; pub use did::Did; -pub use doc::{Doc, DocAt, DocError, IdError, PayloadError, RepoId, Visibility}; +pub use doc::{Doc, DocAt, DocError, IdError, PayloadError, RawDoc, RepoId, Visibility}; pub use project::Project; pub use crate::cob::identity::{Error, Identity, IdentityMut}; diff --git a/radicle/src/identity/doc.rs b/radicle/src/identity/doc.rs index e0b2e854..e47702f2 100644 --- a/radicle/src/identity/doc.rs +++ b/radicle/src/identity/doc.rs @@ -2,7 +2,7 @@ mod id; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; -use std::marker::PhantomData; +use std::num::NonZeroUsize; use std::ops::{Deref, Not}; use std::path::Path; use std::str::FromStr; @@ -17,7 +17,7 @@ use thiserror::Error; use crate::canonical::formatter::CanonicalFormatter; use crate::cob::identity; use crate::crypto; -use crate::crypto::{Signature, Unverified, Verified}; +use crate::crypto::Signature; use crate::git; use crate::identity::{project::Project, Did}; use crate::storage; @@ -37,10 +37,10 @@ pub const MAX_DELEGATES: usize = 255; pub enum DocError { #[error("json: {0}")] Json(#[from] serde_json::Error), - #[error("invalid delegates: {0}")] - Delegates(&'static str), - #[error("invalid threshold `{0}`: {1}")] - Threshold(usize, &'static str), + #[error(transparent)] + Delegates(#[from] DelegatesError), + #[error(transparent)] + Threshold(#[from] ThresholdError), #[error("git: {0}")] GitExt(#[from] git::Error), #[error("git: {0}")] @@ -49,6 +49,14 @@ pub enum DocError { Missing, } +#[derive(Debug, Error)] +#[error("invalid delegates: {0}")] +pub struct DelegatesError(&'static str); + +#[derive(Debug, Error)] +#[error("invalid threshold `{0}`: {1}")] +pub struct ThresholdError(usize, &'static str); + impl DocError { /// Whether this error is caused by the document not being found. pub fn is_not_found(&self) -> bool { @@ -99,7 +107,9 @@ pub enum PayloadError { NotFound(PayloadId), } -/// Payload value. +/// A `Payload` is a free-form JSON value that can be associated with an +/// identity's [`Doc`]. +/// The payload is identified in the [`Doc`] by its corresponding [`PayloadId`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct Payload { @@ -137,25 +147,25 @@ pub struct DocAt { /// The document blob at this commit. pub blob: Oid, /// The parsed document. - pub doc: Doc, + pub doc: Doc, } impl Deref for DocAt { - type Target = Doc; + type Target = Doc; fn deref(&self) -> &Self::Target { &self.doc } } -impl From for Doc { +impl From for Doc { fn from(value: DocAt) -> Self { value.doc } } -impl AsRef> for DocAt { - fn as_ref(&self) -> &Doc { +impl AsRef for DocAt { + fn as_ref(&self) -> &Doc { &self.doc } } @@ -209,106 +219,55 @@ impl Visibility { } } -/// An identity document. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// `RawDoc` is similar to the [`Doc`] type, however, it can be edited and may +/// not be valid. +/// +/// It is expected that any changes to a [`Doc`] are made via [`RawDoc`], and +/// then verified by using [`RawDoc::verified`]. +/// +/// Note that `RawDoc` only implements [`Deserialize`]. This prevents us from +/// serializing an unverified document, while also making sure that any document +/// that is deserialized is verified. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct Doc { +pub struct RawDoc { /// The payload section. pub payload: BTreeMap, /// The delegates section. - pub delegates: NonEmpty, + pub delegates: Vec, /// The signature threshold. pub threshold: usize, /// Repository visibility. - #[serde(default, skip_serializing_if = "Visibility::is_public")] + #[serde(default)] pub visibility: Visibility, - - #[serde(skip)] - verified: PhantomData, } -impl Doc { - /// Check whether this document and the associated repository is visible to the given peer. - pub fn is_visible_to(&self, peer: &PublicKey) -> bool { - match &self.visibility { - Visibility::Public => true, - Visibility::Private { allow } => { - allow.contains(&Did::from(*peer)) || self.is_delegate(peer) - } - } - } +impl TryFrom for Doc { + type Error = DocError; - /// Validate signature using this document's delegates, against a given document blob. - pub fn verify_signature( - &self, - key: &PublicKey, - signature: &Signature, - blob: Oid, - ) -> Result<(), PublicKey> { - if !self.is_delegate(key) { - return Err(*key); - } - if key.verify(blob.as_bytes(), signature).is_err() { - return Err(*key); - } - Ok(()) - } - - pub fn is_majority(&self, votes: usize) -> bool { - votes >= self.majority() - } - - pub fn majority(&self) -> usize { - self.delegates.len() / 2 + 1 - } - - pub fn blob_at(commit: Oid, repo: &R) -> Result { - let path = Path::new("embeds").join(*PATH); - repo.blob_at(commit, path.as_path()).map_err(DocError::from) - } - - pub fn is_delegate(&self, key: &crypto::PublicKey) -> bool { - self.delegates.contains(&key.into()) + fn try_from(doc: RawDoc) -> Result { + doc.verified() } } -impl Doc { - pub fn encode(&self) -> Result<(git::Oid, Vec), DocError> { - let mut buf = Vec::new(); - let mut serializer = - serde_json::Serializer::with_formatter(&mut buf, CanonicalFormatter::new()); +impl RawDoc { + /// Construct a new [`RawDoc`] with an initial [`RawDoc::payload`] + /// containing the provided [`Project`], and the given `delegates`, + /// `threshold`, and `visibility`. + pub fn new( + project: Project, + delegates: Vec, + threshold: usize, + visibility: Visibility, + ) -> Self { + let project = + serde_json::to_value(project).expect("Doc::initial: payload must be serializable"); - 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 - } - - pub fn rescind(&mut self, key: &crypto::PublicKey) -> Result, DocError> { - let delegate = Did::from(key); - let (matches, delegates) = self.delegates.iter().partition(|d| **d == delegate); - match NonEmpty::from_vec(delegates) { - Some(delegates) => { - self.delegates = delegates; - if self.threshold > self.delegates.len() { - return Err(DocError::Threshold( - self.threshold, - "the thresholds exceeds the new delegate count after removal", - )); - } - Ok(matches.is_empty().not().then_some(delegate)) - } - None => Err(DocError::Delegates("cannot remove the last delegate")), + Self { + payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]), + delegates, + threshold, + visibility, } } @@ -323,6 +282,389 @@ impl Doc { Ok(proj) } + /// Check if the given `did` is in the set of [`RawDoc::delegates`]. + pub fn is_delegate(&self, did: &Did) -> bool { + self.delegates.contains(did) + } + + /// Add a new delegate to the document. + /// + /// Note that if this `Did` is a duplicate, then the resulting set will only + /// show it once. + pub fn delegate(&mut self, did: Did) { + self.delegates.push(did) + } + + /// Remove the `did` from the set of delegates. Returns `true` if it was + /// removed. + pub fn rescind(&mut self, did: &Did) -> Result { + let (matches, delegates) = self.delegates.iter().partition(|d| *d == did); + self.delegates = delegates; + Ok(matches.is_empty().not()) + } + + /// Construct the `RawDoc` from the set of `bytes` that are expected to be + /// in JSON format. + pub fn from_json(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(DocError::from) + } + + /// Verify the `RawDoc`'s values, converting it into a valid [`Doc`]. + /// + /// The verifications are as follows: + /// + /// - [`RawDoc::delegates`]: any duplicates are removed, and for the + /// remaining set ensure that it is non-empty and does not exceed a + /// length of [`MAX_DELEGATES`]. + /// - [`RawDoc::threshold`]: ensure that it is in the range `[1, delegates.len()]`. + pub fn verified(self) -> Result { + let RawDoc { + payload, + delegates, + threshold, + visibility, + } = self; + let delegates = Delegates::new(delegates)?; + let threshold = Threshold::new(threshold, &delegates)?; + Ok(Doc { + payload, + delegates, + threshold, + visibility, + }) + } +} + +/// A valid set of delegates for the identity [`Doc`]. +/// +/// It can only be constructed via [`Delegates::new`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "Vec")] +pub struct Delegates(NonEmpty); + +impl AsRef> for Delegates { + fn as_ref(&self) -> &NonEmpty { + &self.0 + } +} + +impl TryFrom> for Delegates { + type Error = DelegatesError; + + fn try_from(dids: Vec) -> Result { + Delegates::new(dids) + } +} + +impl IntoIterator for Delegates { + type Item = as IntoIterator>::Item; + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl Delegates { + /// Construct the set of `Delegates` by removing any duplicate [`Did`]s, + /// ensure that the set is non-empty, and check the length does not exceed + /// the [`MAX_DELEGATES`]. + pub fn new(delegates: impl IntoIterator) -> Result { + let delegates = delegates + .into_iter() + .try_fold(Vec::::new(), |mut dids, did| { + if !dids.contains(&did) { + if dids.len() >= MAX_DELEGATES { + return Err(DelegatesError("number of delegates cannot exceed 255")); + } + dids.push(did); + } + Ok(dids) + })?; + NonEmpty::from_vec(delegates) + .map(Self) + .ok_or(DelegatesError("delegate list cannot be empty")) + } + + /// Get the first delegate in the set. + pub fn first(&self) -> &Did { + self.0.first() + } + + /// Obtain an iterator over the [`Did`]s. + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + /// Check if the set contains the given `did`. + pub fn contains(&self, did: &Did) -> bool { + self.0.contains(did) + } + + /// Get the number of delegates in the set. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Check if the set is empty. Note that this always returns `false`. + pub fn is_empty(&self) -> bool { + false + } +} + +impl<'a> From<&'a Delegates> for &'a NonEmpty { + fn from(ds: &'a Delegates) -> Self { + &ds.0 + } +} + +impl From for NonEmpty { + fn from(ds: Delegates) -> Self { + ds.0 + } +} + +impl From for Vec { + fn from(Delegates(ds): Delegates) -> Self { + ds.into() + } +} + +/// A valid threshold for the identity [`Doc`]. +/// +/// It can only be constructed via [`Threshold::new`] or [`Threshold::MIN`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct Threshold(NonZeroUsize); + +impl From for usize { + fn from(Threshold(t): Threshold) -> Self { + t.get() + } +} + +impl AsRef for Threshold { + fn as_ref(&self) -> &NonZeroUsize { + &self.0 + } +} + +impl fmt::Display for Threshold { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Threshold { + /// A threshold of `1`. + pub const MIN: Threshold = Threshold(NonZeroUsize::MIN); + + /// Construct the `Threshold` by checking that `t` is not greater than + /// [`MAX_DELEGATES`], that it does not exceed the number of delegates, and + /// is non-zero. + pub fn new(t: usize, delegates: &Delegates) -> Result { + if t > MAX_DELEGATES { + Err(ThresholdError(t, "threshold cannot exceed 255")) + } else if t > delegates.len() { + Err(ThresholdError( + t, + "threshold cannot exceed number of delegates", + )) + } else { + NonZeroUsize::new(t) + .map(Self) + .ok_or(ThresholdError(t, "threshold cannot be zero")) + } + } +} + +/// `Doc` is a valid identity document. +/// +/// To ensure that only valid documents are used, this type is restricted to be +/// read-only. For mutating the document use [`Doc::edit`]. +/// +/// A valid `Doc` can be constructed in four ways: +/// +/// 1. [`Doc::initial`]: a safe way to construct the initial document for an identity. +/// 2. [`RawDoc::verified`]: validates a [`RawDoc`]'s fields and converts it +/// into a `Doc` +/// 3. [`Deserialize`]: will deserialize a `Doc` by first deserializing a +/// [`RawDoc`] and use [`RawDoc::verified`] to construct the `Doc`. +/// 4. [`Doc::from_blob`]: construct a `Doc` from a Git blob by deserializing +/// its contents. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(try_from = "RawDoc")] +pub struct Doc { + payload: BTreeMap, + delegates: Delegates, + threshold: Threshold, + #[serde(default, skip_serializing_if = "Visibility::is_public")] + visibility: Visibility, +} + +impl Doc { + /// Construct the initial [`Doc`] for an identity. + /// + /// It will begin with the provided `project` in the [`Doc::payload`], the + /// `delegate` as the sole delegate, a threshold of 1, and the given + /// `visibility`. + pub fn initial(project: Project, delegate: Did, visibility: Visibility) -> 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: Delegates(NonEmpty::new(delegate)), + threshold: Threshold(NonZeroUsize::MIN), + visibility, + } + } + + /// Construct a [`Doc`] contained in the provided Git blob. + pub fn from_blob(blob: &git2::Blob) -> Result { + RawDoc::from_json(blob.content())?.verified() + } + + /// Convert the [`Doc`] into a [`RawDoc`] for changing the field values and + /// re-verifying. + pub fn edit(self) -> RawDoc { + let Doc { + payload, + delegates, + threshold, + visibility, + } = self; + RawDoc { + payload, + delegates: delegates.into(), + threshold: threshold.into(), + visibility, + } + } + + /// Using the current state of the `Doc`, perform any edits on the `RawDoc` + /// form and verify the changes. + pub fn with_edits(self, f: F) -> Result + where + F: FnOnce(&mut RawDoc), + { + let mut raw = self.edit(); + f(&mut raw); + raw.verified() + } + + /// Return the associated payloads for this [`Doc`]. + pub fn payload(&self) -> &BTreeMap { + &self.payload + } + + /// Get the project payload, if it exists and is valid, out of this document. + pub fn project(&self) -> Result { + let value = self + .payload + .get(&PayloadId::project()) + .ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?; + let proj: Project = serde_json::from_value((**value).clone())?; + + Ok(proj) + } + + /// Return the associated [`Visibility`] of this document. + pub fn visibility(&self) -> &Visibility { + &self.visibility + } + + /// Check whether the visibility of the document is public. + pub fn is_public(&self) -> bool { + self.visibility.is_public() + } + + /// Check whether the visibility of the document is private. + pub fn is_private(&self) -> bool { + self.visibility.is_private() + } + + /// Return the associated threshold of this document. + pub fn threshold(&self) -> usize { + self.threshold.into() + } + + /// Return the associated threshold of this document in its non-zero format. + pub fn threshold_nonzero(&self) -> &NonZeroUsize { + &self.threshold.0 + } + + /// Return the associated delegates of this document. + pub fn delegates(&self) -> &Delegates { + &self.delegates + } + + /// Check if the `did` is part of the [`Doc::delegates`] set. + pub fn is_delegate(&self, did: &Did) -> bool { + self.delegates.contains(did) + } + + /// Check whether this document and the associated repository is visible to + /// the given peer. + pub fn is_visible_to(&self, did: &Did) -> bool { + match &self.visibility { + Visibility::Public => true, + Visibility::Private { allow } => allow.contains(did) || self.is_delegate(did), + } + } + + /// Validate `signature` using this document's delegates, against a given + /// document blob. + pub fn verify_signature( + &self, + key: &PublicKey, + signature: &Signature, + blob: Oid, + ) -> Result<(), PublicKey> { + if !self.is_delegate(&key.into()) { + return Err(*key); + } + if key.verify(blob.as_bytes(), signature).is_err() { + return Err(*key); + } + Ok(()) + } + + /// Check the provided `votes` passes the [`Doc::majority`]. + pub fn is_majority(&self, votes: usize) -> bool { + votes >= self.majority() + } + + /// Return the majority number based on the size of the delegates set. + pub fn majority(&self) -> usize { + self.delegates.len() / 2 + 1 + } + + /// Helper for getting an `embeds` Git blob. + pub(crate) fn blob_at( + commit: Oid, + repo: &R, + ) -> Result { + let path = Path::new("embeds").join(*PATH); + repo.blob_at(commit, path.as_path()).map_err(DocError::from) + } + + /// Encode the [`Doc`] as canonical JSON, returning the set of bytes and its + /// corresponding Git [`Oid`]. + pub fn encode(&self) -> Result<(git::Oid, Vec), DocError> { + let mut buf = Vec::new(); + let mut serializer = + serde_json::Serializer::with_formatter(&mut buf, CanonicalFormatter::new()); + + self.serialize(&mut serializer)?; + let oid = git2::Oid::hash_object(git2::ObjectType::Blob, &buf)?; + + Ok((oid.into(), buf)) + } + + /// [`Doc::encode`] and sign the [`Doc`], returning the set of bytes, its + /// corresponding Git [`Oid`] and the [`Signature`] over the [`Oid`]. pub fn sign( &self, signer: &G, @@ -333,15 +675,18 @@ impl Doc { Ok((oid, bytes, sig)) } + /// Similar to [`Doc::sign`], but only returning the [`Signature`]. pub fn signature_of(&self, signer: &G) -> Result { let (_, _, sig) = self.sign(signer)?; Ok(sig) } + /// Load the [`DocAt`] found at the given `commit`. The [`DocAt`] will + /// contain the corresponding [`Doc`]. pub fn load_at(commit: Oid, repo: &R) -> Result { let blob = Self::blob_at(commit, repo)?; - let doc = Doc::from_blob(&blob)?; + let doc = Self::from_blob(&blob)?; Ok(DocAt { commit, @@ -350,10 +695,8 @@ impl Doc { }) } - pub fn from_blob(blob: &git2::Blob) -> Result { - Doc::from_json(blob.content())?.verified() - } - + /// Initialize an [`identity::Identity`] with this [`Doc`] as the associated + /// document. pub fn init( &self, repo: &storage::git::Repository, @@ -378,79 +721,54 @@ impl Doc { } } -impl Doc { - pub fn initial(project: Project, delegate: Did, visibility: Visibility) -> Self { - Self::new(project, NonEmpty::new(delegate), 1, visibility) - } - - pub fn new( - project: Project, - delegates: NonEmpty, - threshold: usize, - visibility: Visibility, - ) -> 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, - visibility, - verified: PhantomData, - } - } - - pub fn from_json(bytes: &[u8]) -> Result { - serde_json::from_slice(bytes).map_err(DocError::from) - } - - pub fn verified(self) -> Result, DocError> { - if self.delegates.len() > MAX_DELEGATES { - return Err(DocError::Delegates("number of delegates cannot exceed 255")); - } - if self.delegates.is_empty() { - return Err(DocError::Delegates("delegate list cannot be empty")); - } - if self.threshold > self.delegates.len() { - return Err(DocError::Threshold( - self.threshold, - "threshold cannot exceed number of delegates", - )); - } - if self.threshold == 0 { - return Err(DocError::Threshold( - self.threshold, - "threshold cannot be zero", - )); - } - - Ok(Doc { - payload: self.payload, - delegates: self.delegates, - threshold: self.threshold, - visibility: self.visibility, - verified: PhantomData, - }) - } -} - #[cfg(test)] #[allow(clippy::unwrap_used)] mod test { use radicle_crypto::test::signer::MockSigner; use radicle_crypto::Signer as _; + use crate::assert_matches; use crate::rad; use crate::storage::git::transport; use crate::storage::git::Storage; use crate::storage::{ReadStorage as _, RemoteId, WriteStorage as _}; use crate::test::arbitrary; + use crate::test::arbitrary::gen; use crate::test::fixtures; use super::*; use qcheck_macros::quickcheck; + #[test] + fn test_duplicate_dids() { + let delegate = MockSigner::from_seed([0xff; 32]); + let did = Did::from(delegate.public_key()); + let mut doc = RawDoc::new(gen::(1), vec![did], 1, Visibility::Public); + doc.delegate(did); + let doc = doc.verified().unwrap(); + assert!(doc.delegates().len() == 1, "Duplicate DID was not removed"); + assert!(doc.delegates().first() == &did) + } + + #[test] + fn test_max_delegates() { + // Generate more than the max delegates + let delegates = (0..MAX_DELEGATES + 1).map(gen).collect::>(); + + // A document with max delegates will be fine + let doc = RawDoc::new( + gen::(1), + delegates[0..MAX_DELEGATES].into(), + 1, + Visibility::Public, + ); + assert_matches!(doc.verified(), Ok(_)); + + // A document that exceeds max delegates should fail + let doc = RawDoc::new(gen::(1), delegates, 1, Visibility::Public); + assert_matches!(doc.verified(), Err(DocError::Delegates(DelegatesError(_)))); + } + #[test] fn test_canonical_example() { let tempdir = tempfile::tempdir().unwrap(); @@ -494,7 +812,7 @@ mod test { let err = repo.identity_head_of(&remote).unwrap_err(); matches!(err, git::ext::Error::NotFound(_)); - let err = Doc::::load_at(oid.into(), &repo).unwrap_err(); + let err = Doc::load_at(oid.into(), &repo).unwrap_err(); assert!(err.is_not_found()); } @@ -523,9 +841,9 @@ mod test { } #[quickcheck] - fn prop_encode_decode(doc: Doc) { + fn prop_encode_decode(doc: Doc) { let (_, bytes) = doc.encode().unwrap(); - assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc); + assert_eq!(RawDoc::from_json(&bytes).unwrap().verified().unwrap(), doc); } #[test] diff --git a/radicle/src/lib.rs b/radicle/src/lib.rs index 2cb90032..1d38b404 100644 --- a/radicle/src/lib.rs +++ b/radicle/src/lib.rs @@ -40,7 +40,7 @@ pub mod prelude { use super::*; pub use crypto::{PublicKey, Signer, Verified}; - pub use identity::{project::Project, Did, Doc, RepoId}; + pub use identity::{project::Project, Did, Doc, RawDoc, RepoId}; pub use node::{Alias, NodeId, Timestamp}; pub use profile::Profile; pub use storage::{ diff --git a/radicle/src/rad.rs b/radicle/src/rad.rs index b3fb88bd..5b08364a 100644 --- a/radicle/src/rad.rs +++ b/radicle/src/rad.rs @@ -52,7 +52,7 @@ pub fn init( visibility: Visibility, signer: &G, storage: S, -) -> Result<(RepoId, identity::Doc, SignedRefs), InitError> { +) -> Result<(RepoId, identity::Doc, SignedRefs), InitError> { // TODO: Better error when project id already exists in storage, but remote doesn't. let delegate: identity::Did = signer.public_key().into(); let proj = Project::new( @@ -68,7 +68,7 @@ pub fn init( .join(", "), ) })?; - let doc = identity::Doc::initial(proj, delegate, visibility).verified()?; + let doc = identity::Doc::initial(proj, delegate, visibility); let (project, identity) = Repository::init(&doc, &storage, signer)?; let url = git::Url::from(project.id); @@ -452,7 +452,7 @@ mod tests { assert_eq!(project.name(), "acme"); assert_eq!(project.description(), "Acme's repo"); assert_eq!(project.default_branch(), &git::refname!("master")); - assert_eq!(doc.delegates.first(), &Did::from(public_key)); + assert_eq!(doc.delegates().first(), &Did::from(public_key)); } #[test] diff --git a/radicle/src/storage.rs b/radicle/src/storage.rs index 3396f2a8..17d2fcde 100644 --- a/radicle/src/storage.rs +++ b/radicle/src/storage.rs @@ -32,13 +32,13 @@ pub type BranchName = git::RefString; /// Basic repository information. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RepositoryInfo { +pub struct RepositoryInfo { /// Repository identifier. pub rid: RepoId, /// Head of default branch. pub head: Oid, /// Identity document. - pub doc: Doc, + pub doc: Doc, /// Local signed refs, if any. /// Repositories with this set to `None` are ones that are seeded but not forked. pub refs: Option, @@ -412,11 +412,11 @@ pub trait ReadStorage { /// Check whether storage contains a repository. fn contains(&self, rid: &RepoId) -> Result; /// Return all repositories (public and private). - fn repositories(&self) -> Result>, Error>; + fn repositories(&self) -> Result, Error>; /// Open or create a read-only repository. fn repository(&self, rid: RepoId) -> Result; /// Get a repository's identity if it exists. - fn get(&self, rid: RepoId) -> Result>, RepositoryError> { + fn get(&self, rid: RepoId) -> Result, RepositoryError> { match self.repository(rid) { Ok(repo) => Ok(Some(repo.identity_doc()?.into())), Err(e) if e.is_not_found() => Ok(None), @@ -569,9 +569,9 @@ pub trait ReadRepository: Sized + ValidateRepository { /// Get repository delegates. fn delegates(&self) -> Result, RepositoryError> { - let doc: Doc<_> = self.identity_doc()?.into(); + let doc = self.identity_doc()?; - Ok(doc.delegates) + Ok(doc.delegates().clone().into()) } /// Get the repository's identity document. @@ -683,7 +683,7 @@ where self.deref().contains(rid) } - fn get(&self, rid: RepoId) -> Result>, RepositoryError> { + fn get(&self, rid: RepoId) -> Result, RepositoryError> { self.deref().get(rid) } @@ -691,7 +691,7 @@ where self.deref().repository(rid) } - fn repositories(&self) -> Result>, Error> { + fn repositories(&self) -> Result, Error> { self.deref().repositories() } } diff --git a/radicle/src/storage/git.rs b/radicle/src/storage/git.rs index 41c27f0a..d4732104 100644 --- a/radicle/src/storage/git.rs +++ b/radicle/src/storage/git.rs @@ -11,10 +11,9 @@ use crypto::{Signer, Verified}; use once_cell::sync::Lazy; use tempfile::TempDir; -use crate::crypto::Unverified; use crate::git::canonical::Canonical; use crate::identity::doc::DocError; -use crate::identity::{doc::DocAt, Doc, RepoId}; +use crate::identity::{Doc, DocAt, RepoId}; use crate::identity::{Identity, Project}; use crate::node::SyncedAt; use crate::storage::refs; @@ -106,7 +105,7 @@ impl ReadStorage for Storage { Repository::open(paths::repository(self, &rid), rid) } - fn repositories(&self) -> Result>, Error> { + fn repositories(&self) -> Result, Error> { let mut repos = Vec::new(); for result in fs::read_dir(&self.path)? { @@ -238,7 +237,7 @@ impl Storage { pub fn repositories_by_id<'a>( &self, mut rids: impl Iterator, - ) -> Result>, RepositoryError> { + ) -> Result, RepositoryError> { rids.try_fold(Vec::new(), |mut infos, rid| { let repo = self.repository(*rid)?; let (_, head) = repo.head()?; @@ -391,7 +390,7 @@ impl Repository { let delegates = self .delegates()? .into_iter() - .map(RemoteId::from) + .map(|did| *did) .collect::>(); let mut deleted = Vec::new(); for id in self.remote_ids()? { @@ -435,7 +434,7 @@ impl Repository { /// Create the repository's identity branch. pub fn init( - doc: &Doc, + doc: &Doc, storage: &S, signer: &G, ) -> Result<(Self, git::Oid), RepositoryError> { @@ -491,7 +490,7 @@ impl Repository { Ok(proj) } - pub fn identity_doc_of(&self, remote: &RemoteId) -> Result, DocError> { + pub fn identity_doc_of(&self, remote: &RemoteId) -> Result { let oid = self.identity_head_of(remote)?; Doc::load_at(oid, self).map(|d| d.into()) } @@ -730,7 +729,7 @@ impl ReadRepository for Repository { } fn identity_doc_at(&self, head: Oid) -> Result { - Doc::::load_at(head, self) + Doc::load_at(head, self) } fn head(&self) -> Result<(Qualified, Oid), RepositoryError> { @@ -748,8 +747,8 @@ impl ReadRepository for Repository { let project = doc.project()?; let branch_ref = git::refs::branch(project.default_branch()); let raw = self.raw(); - let oid = Canonical::default_branch(self, &project, &doc.delegates)? - .quorum(doc.threshold, raw)?; + let oid = Canonical::default_branch(self, &project, doc.delegates().into())? + .quorum(doc.threshold(), raw)?; Ok((branch_ref, oid)) } @@ -794,7 +793,7 @@ impl ReadRepository for Repository { let Ok(root) = self.identity_root_of(&remote) else { continue; }; - let blob = Doc::::blob_at(root, self)?; + let blob = Doc::blob_at(root, self)?; // We've got an identity that goes back to the correct root. if blob.id() == **self.id { diff --git a/radicle/src/storage/refs.rs b/radicle/src/storage/refs.rs index df52e17c..f5abe8c9 100644 --- a/radicle/src/storage/refs.rs +++ b/radicle/src/storage/refs.rs @@ -498,7 +498,7 @@ mod tests { // Alice creates "paris" repo. let (paris_repo, paris_head) = fixtures::repository(tmp.path().join("paris")); - let (paris_rid, mut paris_doc, _) = rad::init( + let (paris_rid, paris_doc, _) = rad::init( &paris_repo, "paris".try_into().unwrap(), "Paris repository", @@ -511,7 +511,7 @@ mod tests { // Alice creates "london" repo. let (london_repo, _london_head) = fixtures::repository(tmp.path().join("london")); - let (london_rid, mut london_doc, _) = rad::init( + let (london_rid, london_doc, _) = rad::init( &london_repo, "london".try_into().unwrap(), "London repository", @@ -532,8 +532,16 @@ mod tests { // Bob is added to both repos as a delegate, by Alice. { - paris_doc.delegates.push(bob.public_key().into()); - london_doc.delegates.push(bob.public_key().into()); + let paris_doc = paris_doc + .with_edits(|doc| { + doc.delegates.push(bob.public_key().into()); + }) + .unwrap(); + let london_doc = london_doc + .with_edits(|doc| { + doc.delegates.push(bob.public_key().into()); + }) + .unwrap(); let mut paris_ident = Identity::load_mut(&paris).unwrap(); let mut london_ident = Identity::load_mut(&london).unwrap(); diff --git a/radicle/src/test/arbitrary.rs b/radicle/src/test/arbitrary.rs index d049a329..a6dd0770 100644 --- a/radicle/src/test/arbitrary.rs +++ b/radicle/src/test/arbitrary.rs @@ -5,17 +5,16 @@ use std::str::FromStr; use std::{iter, net}; use crypto::test::signer::MockSigner; -use crypto::{PublicKey, Unverified, Verified}; +use crypto::{PublicKey, Unverified}; use cyphernet::addr::tor::OnionAddrV3; use cyphernet::EcPk; -use nonempty::NonEmpty; use qcheck::Arbitrary; use crate::collections::RandomMap; use crate::identity::doc::Visibility; use crate::identity::project::ProjectName; use crate::identity::{ - doc::{Doc, DocAt, RepoId}, + doc::{Doc, DocAt, RawDoc, RepoId}, project::Project, Did, }; @@ -139,28 +138,26 @@ impl Arbitrary for Visibility { } } -impl Arbitrary for Doc { +impl Arbitrary for RawDoc { fn arbitrary(g: &mut qcheck::Gen) -> Self { let proj = Project::arbitrary(g); let delegate = Did::arbitrary(g); let visibility = Visibility::arbitrary(g); - Self::initial(proj, delegate, visibility) + Self::new(proj, vec![delegate], 1, visibility) } } -impl Arbitrary for Doc { +impl Arbitrary for Doc { fn arbitrary(g: &mut qcheck::Gen) -> Self { let mut rng = fastrand::Rng::with_seed(u64::arbitrary(g)); let project = Project::arbitrary(g); - let delegates: NonEmpty<_> = iter::repeat_with(|| Did::arbitrary(g)) + let delegates = iter::repeat_with(|| Did::arbitrary(g)) .take(rng.usize(1..6)) - .collect::>() - .try_into() - .unwrap(); + .collect::>(); let threshold = delegates.len() / 2 + 1; let visibility = Visibility::arbitrary(g); - let doc: Doc = Doc::new(project, delegates, threshold, visibility); + let doc = RawDoc::new(project, delegates, threshold, visibility); doc.verified().unwrap() } @@ -168,7 +165,7 @@ impl Arbitrary for Doc { impl Arbitrary for DocAt { fn arbitrary(g: &mut qcheck::Gen) -> Self { - let doc = Doc::::arbitrary(g); + let doc = Doc::arbitrary(g); DocAt { commit: self::oid(), @@ -238,7 +235,7 @@ impl Arbitrary for MockStorage { impl Arbitrary for MockRepository { fn arbitrary(g: &mut qcheck::Gen) -> Self { let rid = RepoId::arbitrary(g); - let doc = Doc::::arbitrary(g); + let doc = Doc::arbitrary(g); Self::new(rid, doc) } diff --git a/radicle/src/test/storage.rs b/radicle/src/test/storage.rs index 96464d15..6a066816 100644 --- a/radicle/src/test/storage.rs +++ b/radicle/src/test/storage.rs @@ -7,7 +7,7 @@ use std::str::FromStr; use git_ext::ref_format as fmt; use crate::crypto::{Signer, Verified}; -use crate::identity::doc::{Doc, DocAt, DocError, RepoId}; +use crate::identity::doc::{Doc, DocAt, DocError, RawDoc, RepoId}; use crate::node::NodeId; pub use crate::storage::*; @@ -51,9 +51,9 @@ impl MockStorage { .expect("MockStorage::repo_mut: repository does not exist") } - pub fn map(mut self, f: impl Fn(&mut Doc)) -> Self { + pub fn map(mut self, f: impl Fn(&mut RawDoc)) -> Self { for repo in self.repos.values_mut() { - f(&mut repo.doc.doc); + repo.doc.doc = repo.doc.doc.clone().with_edits(|doc| f(doc)).unwrap(); } self } @@ -91,7 +91,7 @@ impl ReadStorage for MockStorage { .cloned() } - fn repositories(&self) -> Result>, Error> { + fn repositories(&self) -> Result, Error> { Ok(self .repos .iter() @@ -135,7 +135,7 @@ pub struct MockRepository { } impl MockRepository { - pub fn new(id: RepoId, doc: Doc) -> Self { + pub fn new(id: RepoId, doc: Doc) -> Self { let (blob, _) = doc.encode().unwrap(); Self {