diff --git a/radicle-cli/src/commands/issue.rs b/radicle-cli/src/commands/issue.rs index 8f347e8d..92651f28 100644 --- a/radicle-cli/src/commands/issue.rs +++ b/radicle-cli/src/commands/issue.rs @@ -327,12 +327,13 @@ fn list( let mut all = Vec::new(); for result in issues.all()? { - let (id, issue, _) = result?; - + let Ok((id, issue, _)) = result else { + // Skip issues that failed to load. + continue; + }; if Some(true) == assignee.map(|a| !issue.assigned().any(|v| v == Did::from(a))) { continue; } - all.push((id, issue)) } diff --git a/radicle-cli/src/commands/patch/list.rs b/radicle-cli/src/commands/patch/list.rs index c13a4b93..07b65022 100644 --- a/radicle-cli/src/commands/patch/list.rs +++ b/radicle-cli/src/commands/patch/list.rs @@ -20,8 +20,10 @@ pub fn run( let mut all = Vec::new(); for patch in patches.all()? { - let (id, patch, _) = patch?; - + let Ok((id, patch, _)) = patch else { + // Skip patches that failed to load. + continue; + }; if !filter(patch.state()) { continue; } diff --git a/radicle/src/cob/identity.rs b/radicle/src/cob/identity.rs index 9e0a4791..55df7c2d 100644 --- a/radicle/src/cob/identity.rs +++ b/radicle/src/cob/identity.rs @@ -93,6 +93,9 @@ pub enum ApplyError { /// Error applying an op to the proposal thread. #[error("thread apply failed: {0}")] Thread(#[from] thread::OpError), + /// Error validating the state. + #[error("validation failed: {0}")] + Validate(&'static str), } /// Error committing the proposal. @@ -311,6 +314,13 @@ impl store::FromHistory for Proposal { &*TYPENAME } + fn validate(&self) -> Result<(), Self::Error> { + if self.revisions.is_empty() { + return Err(ApplyError::Validate("no revisions found")); + } + Ok(()) + } + fn apply( &mut self, ops: impl IntoIterator, diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs index aeda5ee1..4ba8b5ae 100644 --- a/radicle/src/cob/issue.rs +++ b/radicle/src/cob/issue.rs @@ -34,6 +34,8 @@ pub type IssueId = ObjectId; pub enum Error { #[error("apply failed")] Apply, + #[error("validation failed: {0}")] + Validate(&'static str), #[error("description missing")] DescriptionMissing, #[error("thread apply failed: {0}")] @@ -134,6 +136,16 @@ impl store::FromHistory for Issue { &*TYPENAME } + fn validate(&self) -> Result<(), Self::Error> { + if self.title.get().is_empty() { + return Err(Error::Validate("title is empty")); + } + if self.thread.validate().is_err() { + return Err(Error::Validate("invalid thread")); + } + Ok(()) + } + fn apply( &mut self, ops: impl IntoIterator, diff --git a/radicle/src/cob/patch.rs b/radicle/src/cob/patch.rs index f55fc7c3..b8a92783 100644 --- a/radicle/src/cob/patch.rs +++ b/radicle/src/cob/patch.rs @@ -75,6 +75,9 @@ pub enum ApplyError { /// Git error. #[error("git: {0}")] Git(#[from] git::ext::Error), + /// Validation error. + #[error("validation failed: {0}")] + Validate(&'static str), } /// Error updating or creating patches. @@ -344,6 +347,16 @@ impl store::FromHistory for Patch { &*TYPENAME } + fn validate(&self) -> Result<(), Self::Error> { + if self.revisions.is_empty() { + return Err(ApplyError::Validate("no revisions found")); + } + if self.title().is_empty() { + return Err(ApplyError::Validate("empty title")); + } + Ok(()) + } + fn apply( &mut self, ops: impl IntoIterator, diff --git a/radicle/src/cob/store.rs b/radicle/src/cob/store.rs index fb83cc45..06dcf7c7 100644 --- a/radicle/src/cob/store.rs +++ b/radicle/src/cob/store.rs @@ -3,6 +3,7 @@ #![allow(clippy::type_complexity)] use std::marker::PhantomData; use std::ops::ControlFlow; +use std::sync::Arc; use nonempty::NonEmpty; use radicle_crdt::Lamport; @@ -28,11 +29,11 @@ pub trait HistoryAction { /// A type that can be materialized from an event history. /// All collaborative objects implement this trait. -pub trait FromHistory: Sized + Default { +pub trait FromHistory: Sized + Default + PartialEq { /// The underlying action composing each operation. type Action: HistoryAction + for<'de> Deserialize<'de> + Serialize; /// Error returned by `apply` function. - type Error: std::error::Error; + type Error: std::error::Error + Send + Sync + 'static; /// The object type name. fn type_name() -> &'static TypeName; @@ -44,11 +45,14 @@ pub trait FromHistory: Sized + Default { repo: &R, ) -> Result<(), Self::Error>; + /// Validate the object. Returns an error if the object is invalid. + fn validate(&self) -> Result<(), Self::Error>; + /// Create an object from a history. fn from_history( history: &History, repo: &R, - ) -> Result<(Self, Lamport), Error> { + ) -> Result<(Self, Lamport), Self::Error> { let obj = history.traverse(Self::default(), |mut acc, entry| { match Ops::try_from(entry) { Ok(Ops(ops)) => { @@ -68,6 +72,8 @@ pub trait FromHistory: Sized + Default { ControlFlow::Continue(acc) }); + obj.validate()?; + Ok((obj, history.clock().into())) } @@ -103,6 +109,8 @@ pub enum Error { HistoryType(String), #[error("object `{1}` of type `{0}` was not found")] NotFound(TypeName, ObjectId), + #[error("apply: {0}")] + Apply(Arc), #[error("signed refs: {0}")] SignRefs(#[from] storage::Error), #[error("failed to find reference '{name}': {err}")] @@ -113,6 +121,12 @@ pub enum Error { }, } +impl Error { + fn apply(e: impl std::error::Error + Sync + Send + 'static) -> Self { + Self::Apply(Arc::new(e)) + } +} + /// Storage for collaborative objects of a specific type `T` in a single repository. pub struct Store<'a, T> { identity: git::Oid, @@ -197,7 +211,7 @@ where contents, }, )?; - let (object, clock) = T::from_history(cob.history(), self.repo)?; + let (object, clock) = T::from_history(cob.history(), self.repo).map_err(Error::apply)?; self.repo.sign_refs(signer).map_err(Error::SignRefs)?; @@ -212,7 +226,7 @@ where if cob.manifest().history_type != HISTORY_TYPE { return Err(Error::HistoryType(cob.manifest().history_type.clone())); } - let (obj, clock) = T::from_history(cob.history(), self.repo)?; + let (obj, clock) = T::from_history(cob.history(), self.repo).map_err(Error::apply)?; Ok(Some((obj, clock))) } else { @@ -227,7 +241,7 @@ where let raw = cob::list(self.repo, T::type_name())?; Ok(raw.into_iter().map(|o| { - let (obj, clock) = T::from_history(o.history(), self.repo)?; + let (obj, clock) = T::from_history(o.history(), self.repo).map_err(Error::apply)?; Ok((*o.id(), obj, clock)) })) } diff --git a/radicle/src/cob/thread.rs b/radicle/src/cob/thread.rs index b896fd92..33539ca0 100644 --- a/radicle/src/cob/thread.rs +++ b/radicle/src/cob/thread.rs @@ -31,6 +31,9 @@ pub enum OpError { /// that hasn't happened yet. #[error("causal dependency {0:?} missing")] Missing(EntryId), + /// Validation error. + #[error("validation failed: {0}")] + Validate(&'static str), } /// Identifies a comment. @@ -264,6 +267,13 @@ impl cob::store::FromHistory for Thread { &*TYPENAME } + fn validate(&self) -> Result<(), Self::Error> { + if self.comments.is_empty() { + return Err(OpError::Validate("no comments found")); + } + Ok(()) + } + fn apply( &mut self, ops: impl IntoIterator>,