Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Remove COB entries from evaluation
Draft fintohaps opened 1 year ago

Allow COB evaluation to ignore entries while evaluating the change graph. This would then be wired up to use the node’s block list to ignore the entries for those specific peers.

Open questions:

  • Need to test what happens when replies from non-blocked peers occur
  • This currently just hides data, rather than deleting it
  • Possibly more questions to be had…
13 files changed +283 -41 f30760d6 b5fa357d
modified radicle-cob/src/change/store.rs
@@ -1,6 +1,6 @@
// Copyright © 2022 The Radicle Link Contributors

-
use std::{error::Error, fmt, num::NonZeroUsize};
+
use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};

use nonempty::NonEmpty;
use radicle_git_ext::Oid;
@@ -49,6 +49,26 @@ pub struct Template<Id> {
    pub contents: NonEmpty<Vec<u8>>,
}

+
#[derive(Clone, Debug, Default)]
+
pub struct Filter {
+
    /// Filter out a blocked set of keys.
+
    blocked: BTreeSet<crypto::PublicKey>,
+
    // TODO(finto): we could also add filters for the `Contents`
+
}
+

+
impl Filter {
+
    /// Add the set of `keys` to the blocked list.
+
    pub fn block(mut self, keys: BTreeSet<crypto::PublicKey>) -> Self {
+
        self.blocked.extend(keys);
+
        self
+
    }
+

+
    /// Check if the given `key` is blocked.
+
    pub fn is_blocked(&self, key: &crypto::PublicKey) -> bool {
+
        self.blocked.contains(key)
+
    }
+
}
+

/// Entry contents.
/// This is the change payload.
pub type Contents = NonEmpty<Vec<u8>>;
modified radicle-cob/src/change_graph.rs
@@ -6,6 +6,7 @@ use std::{cmp::Ordering, collections::BTreeSet};
use git_ext::Oid;
use radicle_dag::Dag;

+
use crate::change::store::Filter;
use crate::{
    change, object, object::collaboration::Evaluate, signatures::ExtendedSignature,
    CollaborativeObject, Entry, EntryId, History, ObjectId, TypeName,
@@ -95,6 +96,7 @@ impl ChangeGraph {
    pub(crate) fn evaluate<S, T: Evaluate<S>>(
        mut self,
        store: &S,
+
        filter: &Filter,
    ) -> Result<CollaborativeObject<T>, EvaluateError> {
        let root = *self.object_id;
        let root = self
@@ -116,6 +118,12 @@ impl ChangeGraph {
        self.graph.prune_by(
            &children,
            |_, entry, siblings| {
+
                // Ignore author entries that are blocked, but still try to
+
                // process the other entries.
+
                if filter.is_blocked(entry.value.author()) {
+
                    return ControlFlow::Continue(());
+
                }
+

                // Check the entry signatures are valid.
                if !entry.valid_signatures() {
                    return ControlFlow::Break(());
modified radicle-cob/src/lib.rs
@@ -69,7 +69,7 @@ mod change_graph;
mod trailers;

pub mod change;
-
pub use change::store::{Contents, Embed, EntryId, Manifest, Version};
+
pub use change::store::{Contents, Embed, EntryId, Filter, Manifest, Version};
pub use change::Entry;

pub mod history;
modified radicle-cob/src/object/collaboration/get.rs
@@ -1,6 +1,9 @@
// Copyright © 2022 The Radicle Link Contributors

-
use crate::{change_graph::ChangeGraph, CollaborativeObject, Evaluate, ObjectId, Store, TypeName};
+
use crate::{
+
    change::store::Filter, change_graph::ChangeGraph, CollaborativeObject, Evaluate, ObjectId,
+
    Store, TypeName,
+
};

use super::error;

@@ -17,6 +20,7 @@ pub fn get<T, S>(
    storage: &S,
    typename: &TypeName,
    oid: &ObjectId,
+
    filter: &Filter,
) -> Result<Option<CollaborativeObject<T>>, error::Retrieve>
where
    T: Evaluate<S>,
@@ -27,6 +31,10 @@ where
        .map_err(|err| error::Retrieve::Refs { err: Box::new(err) })?;

    ChangeGraph::load(storage, tip_refs.iter(), typename, oid)
-
        .map(|graph| graph.evaluate(storage).map_err(error::Retrieve::evaluate))
+
        .map(|graph| {
+
            graph
+
                .evaluate(storage, filter)
+
                .map_err(error::Retrieve::evaluate)
+
        })
        .transpose()
}
modified radicle-cob/src/object/collaboration/list.rs
@@ -1,6 +1,9 @@
// Copyright © 2022 The Radicle Link Contributors

-
use crate::{change_graph::ChangeGraph, CollaborativeObject, Evaluate, Store, TypeName};
+
use crate::{
+
    change::store::Filter, change_graph::ChangeGraph, CollaborativeObject, Evaluate, Store,
+
    TypeName,
+
};

use super::error;

@@ -26,8 +29,11 @@ where
    let mut result = Vec::new();
    for (oid, tip_refs) in references {
        log::trace!(target: "cob", "Loading object '{oid}'");
-
        let loaded = ChangeGraph::load(storage, tip_refs.iter(), typename, &oid)
-
            .map(|graph| graph.evaluate(storage).map_err(error::Retrieve::evaluate));
+
        let loaded = ChangeGraph::load(storage, tip_refs.iter(), typename, &oid).map(|graph| {
+
            graph
+
                .evaluate(storage, &Filter::default())
+
                .map_err(error::Retrieve::evaluate)
+
        });

        match loaded {
            Some(Ok(obj)) => {
modified radicle-cob/src/object/collaboration/update.rs
@@ -6,8 +6,10 @@ use nonempty::NonEmpty;
use radicle_crypto::PublicKey;

use crate::{
-
    change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, Embed, Evaluate,
-
    ObjectId, Store, TypeName,
+
    change::{self, store::Filter},
+
    change_graph::ChangeGraph,
+
    history::EntryId,
+
    CollaborativeObject, Embed, Evaluate, ObjectId, Store, TypeName,
};

use super::error;
@@ -83,8 +85,9 @@ where

    let graph = ChangeGraph::load(storage, existing_refs.iter(), typename, &object_id)
        .ok_or(error::Update::NoSuchObject)?;
-
    let mut object: CollaborativeObject<T> =
-
        graph.evaluate(storage).map_err(error::Update::evaluate)?;
+
    let mut object: CollaborativeObject<T> = graph
+
        .evaluate(storage, &Filter::default())
+
        .map_err(error::Update::evaluate)?;

    // Create a commit for this change, but don't update any references yet.
    let entry = storage.store(
modified radicle-cob/src/tests.rs
@@ -6,6 +6,7 @@ use git_ext::ref_format::{refname, Component, RefString};
use nonempty::{nonempty, NonEmpty};
use qcheck::Arbitrary;

+
use crate::Filter;
use crate::{
    create, get, list, object, test::arbitrary::Invalid, update, Create, Entry, ObjectId, TypeName,
    Update, Updated, Version,
@@ -40,7 +41,7 @@ fn roundtrip() {
    )
    .unwrap();

-
    let expected = get(&storage, &typename, cob.id())
+
    let expected = get(&storage, &typename, cob.id(), Filter::default())
        .unwrap()
        .expect("BUG: cob was missing");

@@ -126,7 +127,7 @@ fn update_cob() {
    )
    .unwrap();

-
    let not_expected = get::<NonEmpty<Entry>, _>(&storage, &typename, cob.id())
+
    let not_expected = get::<NonEmpty<Entry>, _>(&storage, &typename, cob.id(), Filter::default())
        .unwrap()
        .expect("BUG: cob was missing");

@@ -146,7 +147,7 @@ fn update_cob() {
    )
    .unwrap();

-
    let expected = get(&storage, &typename, object.id())
+
    let expected = get(&storage, &typename, object.id(), Filter::default())
        .unwrap()
        .expect("BUG: cob was missing");

@@ -235,6 +236,88 @@ fn traverse_cobs() {
    assert_eq!(contents, vec![b"issue 1".to_vec(), b"issue 2".to_vec()]);
}

+
#[test]
+
fn block_entries_in_get() {
+
    let storage = test::Storage::new();
+
    let neil_signer = gen::<MockSigner>(2);
+
    let neil = test::Person::new(&storage, "gaiman", *neil_signer.public_key()).unwrap();
+
    let terry_signer = gen::<MockSigner>(1);
+
    let terry = test::Person::new(&storage, "pratchett", *terry_signer.public_key()).unwrap();
+
    let proj = test::Project::new(&storage, "discworld", *terry_signer.public_key()).unwrap();
+
    let terry_proj = test::RemoteProject {
+
        project: proj.clone(),
+
        person: terry,
+
    };
+
    let neil_proj = test::RemoteProject {
+
        project: proj,
+
        person: neil,
+
    };
+
    let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
+
    let cob = create::<NonEmpty<Entry>, _, _>(
+
        &storage,
+
        &terry_signer,
+
        Some(terry_proj.project.content_id),
+
        vec![],
+
        terry_signer.public_key(),
+
        Create {
+
            contents: nonempty!(b"issue 1".to_vec()),
+
            type_name: typename.clone(),
+
            message: "creating xyz.rad.issue".to_string(),
+
            embeds: vec![],
+
            version: Version::default(),
+
        },
+
    )
+
    .unwrap();
+
    copy_to(
+
        storage.as_raw(),
+
        terry_signer.public_key(),
+
        &neil_proj,
+
        &typename,
+
        *cob.id(),
+
    )
+
    .unwrap();
+

+
    let Updated { .. } = update::<NonEmpty<Entry>, _, _>(
+
        &storage,
+
        &neil_signer,
+
        Some(neil_proj.project.content_id),
+
        vec![],
+
        neil_signer.public_key(),
+
        Update {
+
            changes: nonempty!(b"issue 2".to_vec()),
+
            object_id: *cob.id(),
+
            type_name: typename.clone(),
+
            embeds: vec![],
+
            message: "commenting on xyz.rad.issue".to_string(),
+
        },
+
    )
+
    .unwrap();
+

+
    let not_blocked_issue =
+
        get::<NonEmpty<Entry>, _>(&storage, &typename, cob.id(), Filter::default()).unwrap();
+
    assert_eq!(
+
        not_blocked_issue
+
            .unwrap()
+
            .object
+
            .map(|entry| entry.contents),
+
        nonempty![
+
            nonempty![b"issue 1".to_vec()],
+
            nonempty![b"issue 2".to_vec()]
+
        ],
+
    );
+
    let blocked_issue = get::<NonEmpty<Entry>, _>(
+
        &storage,
+
        &typename,
+
        cob.id(),
+
        Filter::default().block([*neil_signer.public_key()].into_iter().collect()),
+
    )
+
    .unwrap();
+
    assert_eq!(
+
        blocked_issue.unwrap().object.map(|entry| entry.contents),
+
        nonempty![nonempty![b"issue 1".to_vec()]],
+
    );
+
}
+

#[quickcheck]
fn parse_refstr(oid: ObjectId, typename: TypeName) {
    let suffix = refname!("refs/cobs")
modified radicle/src/cob.rs
@@ -18,8 +18,8 @@ pub use common::*;
pub use op::{ActorId, Op};
pub use radicle_cob::{
    change, history::EntryId, object, object::collaboration::error, type_name::TypeNameParse,
-
    CollaborativeObject, Contents, Create, Embed, Entry, Evaluate, History, Manifest, ObjectId,
-
    Store, TypeName, Update, Updated, Version,
+
    CollaborativeObject, Contents, Create, Embed, Entry, Evaluate, Filter, History, Manifest,
+
    ObjectId, Store, TypeName, Update, Updated, Version,
};
pub use radicle_cob::{create, get, git, list, remove, update};

modified radicle/src/cob/identity.rs
@@ -218,7 +218,7 @@ impl Identity {
    ) -> Result<Identity, store::Error> {
        use cob::store::CobWithType;

-
        cob::get::<Self, _>(repo, Self::type_name(), object)
+
        cob::get::<Self, _>(repo, Self::type_name(), object, &cob::Filter::default())
            .map(|r| r.map(|cob| cob.object))?
            .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *object))
    }
modified radicle/src/cob/issue.rs
@@ -770,12 +770,23 @@ where
    R: ReadRepository + cob::Store,
{
    /// Open an issues store.
-
    pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
+
    pub fn open(
+
        repository: &'a R,
+
        blocked: BTreeSet<crypto::PublicKey>,
+
    ) -> Result<Self, RepositoryError> {
        let identity = repository.identity_head()?;
-
        let raw = store::Store::open(repository)?.identity(identity);
+
        let raw = store::Store::open(repository)?
+
            .identity(identity)
+
            .block(blocked);

        Ok(Self { raw })
    }
+

+
    pub fn with_blocked(self, blocked: BTreeSet<crypto::PublicKey>) -> Self {
+
        Self {
+
            raw: self.raw.block(blocked),
+
        }
+
    }
}

impl<'a, R> Issues<'a, R>
@@ -1654,9 +1665,14 @@ mod test {
        issue.reload().unwrap();
        assert_eq!(issue.comments().count(), 1);

-
        let cob = cob::get::<Issue, _>(&*repo, Issue::type_name(), issue.id())
-
            .unwrap()
-
            .unwrap();
+
        let cob = cob::get::<Issue, _>(
+
            &*repo,
+
            Issue::type_name(),
+
            issue.id(),
+
            &cob::Filter::default(),
+
        )
+
        .unwrap()
+
        .unwrap();

        assert_eq!(cob.history().len(), 1);
        assert_eq!(
@@ -1740,9 +1756,14 @@ mod test {
            .unwrap();

        // Initially, there is one node in the DAG.
-
        let cob = cob::get::<NonEmpty<cob::Entry>, _>(&*repo, &type_name, issue.id())
-
            .unwrap()
-
            .unwrap();
+
        let cob = cob::get::<NonEmpty<cob::Entry>, _>(
+
            &*repo,
+
            &type_name,
+
            issue.id(),
+
            &cob::Filter::default(),
+
        )
+
        .unwrap()
+
        .unwrap();

        assert_eq!(cob.history.len(), 1);
        assert_eq!(cob.object.len(), 1);
@@ -1774,16 +1795,21 @@ mod test {

        // If we fetch the COB with its history, *without* trying to interpret it as an issue,
        // we'll see that all entries, including the invalid one are there.
-
        let cob = cob::get::<NonEmpty<cob::Entry>, _>(&*repo, &type_name, issue.id())
-
            .unwrap()
-
            .unwrap();
+
        let cob = cob::get::<NonEmpty<cob::Entry>, _>(
+
            &*repo,
+
            &type_name,
+
            issue.id(),
+
            &cob::Filter::default(),
+
        )
+
        .unwrap()
+
        .unwrap();

        assert_eq!(cob.history.len(), 2);
        assert_eq!(cob.object.len(), 2);
        assert_eq!(cob.object.last().contents(), &contents);

        // However, if we try to fetch it as an *issue*, the invalid comment is pruned.
-
        let cob = cob::get::<Issue, _>(&*repo, &type_name, issue.id())
+
        let cob = cob::get::<Issue, _>(&*repo, &type_name, issue.id(), &cob::Filter::default())
            .unwrap()
            .unwrap();
        assert_eq!(cob.history.len(), 1);
@@ -1801,9 +1827,14 @@ mod test {
        assert_eq!(issue.comments().last().unwrap().1.body(), "Valid comment");

        // The actual DAG contains 3 nodes, but only 2 were loaded as an issue.
-
        let cob = cob::get::<NonEmpty<cob::Entry>, _>(&*repo, &type_name, issue.id())
-
            .unwrap()
-
            .unwrap();
+
        let cob = cob::get::<NonEmpty<cob::Entry>, _>(
+
            &*repo,
+
            &type_name,
+
            issue.id(),
+
            &cob::Filter::default(),
+
        )
+
        .unwrap()
+
        .unwrap();

        assert_eq!(cob.history.len(), 3);
        assert_eq!(cob.object.len(), 3);
@@ -1814,9 +1845,14 @@ mod test {
            .comment("Eve's comment", *issue.id, vec![], &eve)
            .unwrap();

-
        let cob = cob::get::<NonEmpty<cob::Entry>, _>(&*repo, &type_name, issue.id())
-
            .unwrap()
-
            .unwrap();
+
        let cob = cob::get::<NonEmpty<cob::Entry>, _>(
+
            &*repo,
+
            &type_name,
+
            issue.id(),
+
            &cob::Filter::default(),
+
        )
+
        .unwrap()
+
        .unwrap();

        // There are three nodes still, but they are all valid comments.
        // The invalid comment of Eve was replaced with a valid one.
@@ -1824,4 +1860,42 @@ mod test {
        assert_eq!(cob.history.len(), 3);
        assert_eq!(cob.object.len(), 3);
    }
+

+
    #[test]
+
    fn test_blocked_user_comments() {
+
        let t = test::setup::Network::default();
+
        let mut issues_alice = Cache::no_cache(&*t.alice.repo).unwrap();
+
        let mut bob_issues = Cache::no_cache(&*t.bob.repo).unwrap();
+
        let mut issue_alice = issues_alice
+
            .create(
+
                "Alice Issue",
+
                "Alice's comment",
+
                &[],
+
                &[],
+
                [],
+
                &t.alice.signer,
+
            )
+
            .unwrap();
+
        let id = *issue_alice.id();
+

+
        t.bob.repo.fetch(&t.alice);
+

+
        let mut issue_bob = bob_issues.get_mut(&id).unwrap();
+

+
        issue_bob
+
            .comment("Illicit", *id, vec![], &t.bob.signer)
+
            .unwrap();
+
        issue_bob.comment("Ok", *id, vec![], &t.bob.signer).unwrap();
+

+
        t.alice.repo.fetch(&t.bob);
+
        issue_alice.reload().unwrap();
+
        assert_eq!(issue_alice.comments().count(), 3);
+

+
        // Block `bob`'s comments and reload the issue
+
        let issues_alice =
+
            issues_alice.with_blocked([*t.bob.signer.public_key()].into_iter().collect());
+
        let issue_alice = issues_alice.get(&id).unwrap().unwrap();
+

+
        assert_eq!(issue_alice.comments().count(), 1);
+
    }
}
modified radicle/src/cob/issue/cache.rs
@@ -1,3 +1,4 @@
+
use std::collections::BTreeSet;
use std::ops::ControlFlow;
use std::str::FromStr;

@@ -204,13 +205,21 @@ where
    /// Get a `Cache` that does no write-through modifications and
    /// uses the [`super::Issues`] store for all reads and writes.
    pub fn no_cache(repository: &'a R) -> Result<Self, RepositoryError> {
-
        let store = super::Issues::open(repository)?;
+
        // TODO(finto): might need to thread through the blocked list
+
        let store = super::Issues::open(repository, BTreeSet::new())?;
        Ok(Self {
            store,
            cache: cache::NoCache,
        })
    }

+
    pub fn with_blocked(self, blocked: BTreeSet<crypto::PublicKey>) -> Self {
+
        Self {
+
            store: self.store.with_blocked(blocked),
+
            ..self
+
        }
+
    }
+

    /// Get the [`IssueMut`], identified by `id`.
    pub fn get_mut<'g>(
        &'g mut self,
modified radicle/src/cob/store.rs
@@ -1,11 +1,12 @@
//! Generic COB storage.
#![allow(clippy::large_enum_variant)]
#![allow(clippy::type_complexity)]
+
use std::collections::BTreeSet;
use std::fmt::Debug;
use std::marker::PhantomData;

use nonempty::NonEmpty;
-
use radicle_cob::CollaborativeObject;
+
use radicle_cob::{CollaborativeObject, Filter};
use serde::{Deserialize, Serialize};

use crate::cob::op::Op;
@@ -138,6 +139,7 @@ pub struct Store<'a, T, R> {
    repo: &'a R,
    witness: PhantomData<T>,
    type_name: &'a TypeName,
+
    filter: Filter,
}

impl<T, R> AsRef<R> for Store<'_, T, R> {
@@ -146,6 +148,14 @@ impl<T, R> AsRef<R> for Store<'_, T, R> {
    }
}

+
impl<T, R> Store<'_, T, R> {
+
    /// Block the set of keys provided, when evaluating the value for `T`.
+
    pub fn block(self, keys: BTreeSet<crypto::PublicKey>) -> Self {
+
        let filter = self.filter.block(keys);
+
        Self { filter, ..self }
+
    }
+
}
+

impl<'a, T, R> Store<'a, T, R>
where
    R: ReadRepository + cob::Store,
@@ -157,6 +167,7 @@ where
            identity: None,
            witness: PhantomData,
            type_name,
+
            filter: Filter::default(),
        })
    }

@@ -167,6 +178,7 @@ where
            witness: self.witness,
            identity: Some(identity),
            type_name: self.type_name,
+
            filter: Filter::default(),
        }
    }
}
@@ -183,6 +195,7 @@ where
            identity: None,
            witness: PhantomData,
            type_name: T::type_name(),
+
            filter: Filter::default(),
        })
    }
}
@@ -326,7 +339,7 @@ where
{
    /// Get an object.
    pub fn get(&self, id: &ObjectId) -> Result<Option<T>, Error> {
-
        cob::get::<T, _>(self.repo, self.type_name, id)
+
        cob::get::<T, _>(self.repo, self.type_name, id, &self.filter)
            .map(|r| r.map(|cob| cob.object))
            .map_err(Error::from)
    }
@@ -497,8 +510,9 @@ pub fn ops<R: cob::Store>(
    id: &ObjectId,
    type_name: &TypeName,
    repo: &R,
+
    filter: &Filter,
) -> Result<NonEmpty<Op<Vec<u8>>>, Error> {
-
    let cob = cob::get::<NonEmpty<cob::Entry>, _>(repo, type_name, id)?;
+
    let cob = cob::get::<NonEmpty<cob::Entry>, _>(repo, type_name, id, filter)?;

    if let Some(cob) = cob {
        Ok(cob.object.map(Op::from))
modified radicle/src/profile.rs
@@ -607,7 +607,15 @@ impl Home {
        R: ReadRepository + cob::Store,
    {
        let db = self.cobs_db()?;
-
        let store = cob::issue::Issues::open(repository)?;
+
        let policy_store = self.policies()?;
+
        let blocked = policy_store
+
            .follow_policies()?
+
            .filter_map(|entry| match entry.policy {
+
                policy::Policy::Allow => None,
+
                policy::Policy::Block => Some(entry.nid),
+
            })
+
            .collect();
+
        let store = cob::issue::Issues::open(repository, blocked)?;

        db.check_version()?;

@@ -623,7 +631,16 @@ impl Home {
        R: ReadRepository + cob::Store,
    {
        let db = self.cobs_db_mut()?;
-
        let store = cob::issue::Issues::open(repository)?;
+
        // TODO(finto): add a helper
+
        let policy_store = self.policies()?;
+
        let blocked = policy_store
+
            .follow_policies()?
+
            .filter_map(|entry| match entry.policy {
+
                policy::Policy::Allow => None,
+
                policy::Policy::Block => Some(entry.nid),
+
            })
+
            .collect();
+
        let store = cob::issue::Issues::open(repository, blocked)?;

        db.check_version()?;