tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 2cef5dcfb67301716fa9cf8af0824e8c2848a9bc
parent bd21aa73a6f854389475cd9c77cf932ca4ed717c
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 04:01:02 -0700

groups: gate policy through event views

- Refactor group classification, tag extraction, write validation, write policy, and read gate APIs to accept GroupEventView.
- Keep existing Tangle event callers working while allowing Pocket events to enter the same group-domain logic.
- Add direct Pocket-event tests for classification, client group validation, and read screening.
- Validated with cargo fmt --all -- --check, cargo check --workspace --all-targets, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings.

Diffstat:
Mcrates/tangle_groups/src/classification.rs | 42+++++++++++++++++++++++++++++++++---------
Mcrates/tangle_groups/src/event_view.rs | 104+++++++++++++++++++++++++++++++++----------------------------------------------
Mcrates/tangle_groups/src/policy.rs | 90++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/tangle_groups/src/read_gate.rs | 44+++++++++++++++++++++++++++++++++++---------
Mcrates/tangle_groups/src/tags.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/tangle_groups/src/write_gate.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
6 files changed, 284 insertions(+), 185 deletions(-)

diff --git a/crates/tangle_groups/src/classification.rs b/crates/tangle_groups/src/classification.rs @@ -1,11 +1,12 @@ use crate::{ GroupLimitsConfig, errors::GroupError, + event_view::GroupEventView, ids::GroupId, kinds::{is_moderation_kind, is_relay_generated_kind, is_user_request_kind}, tags::{GroupTagName, extract_group_tag, has_group_identity_tag, require_group_tag}, }; -use tangle_protocol::{Event, Kind}; +use tangle_protocol::Kind; #[derive(Debug, Clone, PartialEq, Eq)] pub enum GroupEventClass { @@ -31,31 +32,30 @@ impl GroupEventClass { } pub fn classify_group_event( - event: &Event, + event: &(impl GroupEventView + ?Sized), limits: GroupLimitsConfig, ) -> Result<GroupEventClass, GroupError> { - let kind = event.unsigned().kind(); + let kind = event.kind()?; if is_relay_generated_kind(kind) { - let group_id = require_group_tag(event.unsigned().tags(), GroupTagName::D, limits)? + let group_id = require_group_tag(event, GroupTagName::D, limits)? .group_id() .clone(); return Ok(GroupEventClass::RelayGeneratedSnapshot { kind, group_id }); } if is_moderation_kind(kind) { - let group_id = require_group_tag(event.unsigned().tags(), GroupTagName::H, limits)? + let group_id = require_group_tag(event, GroupTagName::H, limits)? .group_id() .clone(); return Ok(GroupEventClass::Moderation { kind, group_id }); } if is_user_request_kind(kind) { - let group_id = require_group_tag(event.unsigned().tags(), GroupTagName::H, limits)? + let group_id = require_group_tag(event, GroupTagName::H, limits)? .group_id() .clone(); return Ok(GroupEventClass::Normal { group_id }); } - if has_group_identity_tag(event.unsigned().tags()) - && let Some(group_tag) = - extract_group_tag(event.unsigned().tags(), GroupTagName::H, limits)? + if has_group_identity_tag(event)? + && let Some(group_tag) = extract_group_tag(event, GroupTagName::H, limits)? { return Ok(GroupEventClass::Normal { group_id: group_tag.group_id().clone(), @@ -71,8 +71,10 @@ mod tests { GroupErrorKind, GroupLimitsConfig, KIND_GROUP_CREATE_GROUP, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, }; + use pocket_types::Event as PocketEvent; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + event_to_value, }; #[test] @@ -153,6 +155,22 @@ mod tests { } #[test] + fn classifies_pocket_events_through_event_view() { + let event = event( + KIND_GROUP_PUT_USER, + vec![Tag::from_parts("h", &["Farm"]).expect("h")], + ); + let mut buffer = vec![0; 4096]; + let pocket = pocket_event(&event, &mut buffer); + + assert!(matches!( + classify_group_event(pocket, GroupLimitsConfig::default()).expect("pocket"), + GroupEventClass::Moderation { kind, group_id } + if kind.as_u32() == KIND_GROUP_PUT_USER && group_id.as_str() == "Farm" + )); + } + + #[test] fn required_h_and_d_tag_rules_are_strict() { assert_eq!( classify_group_event( @@ -199,4 +217,10 @@ mod tests { SignatureHex::new(&"2".repeat(128)).expect("sig"), ) } + + fn pocket_event<'a>(event: &Event, buffer: &'a mut [u8]) -> &'a PocketEvent { + let raw = event_to_value(event).to_string(); + let (_, pocket) = PocketEvent::from_json(raw.as_bytes(), buffer).expect("pocket"); + pocket + } } diff --git a/crates/tangle_groups/src/event_view.rs b/crates/tangle_groups/src/event_view.rs @@ -1,9 +1,7 @@ use crate::errors::{GroupError, GroupErrorKind}; -use pocket_types::{ - Event as PocketEvent, OwnedEvent as PocketOwnedEvent, TagsStringIter as PocketTagsStringIter, -}; +use pocket_types::{Event as PocketEvent, OwnedEvent as PocketOwnedEvent, TagsStringIter}; use std::str; -use tangle_protocol::{Event, Tag, TagName}; +use tangle_protocol::{Event, EventId, Kind, PublicKeyHex, Tag, TagName}; pub trait GroupEventView { fn id_hex(&self) -> String; @@ -15,47 +13,52 @@ pub trait GroupEventView { fn visit_tags<'a, F>(&'a self, visitor: F) -> Result<(), GroupError> where F: FnMut(GroupEventTag<'a>) -> Result<(), GroupError>; + + fn id(&self) -> Result<EventId, GroupError> { + EventId::new(&self.id_hex()).map_err(event_view_scalar_error) + } + + fn pubkey(&self) -> Result<PublicKeyHex, GroupError> { + PublicKeyHex::new(&self.pubkey_hex()).map_err(event_view_scalar_error) + } + + fn kind(&self) -> Result<Kind, GroupError> { + Kind::new(u64::from(self.kind_u32())).map_err(event_view_scalar_error) + } } -#[derive(Debug)] -pub enum GroupEventTag<'a> { - Tangle(&'a Tag), - Pocket(PocketTagsStringIter<'a>), +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GroupEventTag<'a> { + name: Option<&'a str>, + value: Option<&'a str>, } impl<'a> GroupEventTag<'a> { - pub fn first_value(self) -> Result<Option<&'a str>, GroupError> { - match self { - Self::Tangle(tag) => Ok(tag.values().first().map(String::as_str)), - Self::Pocket(mut values) => values.next().map(tag_value_utf8).transpose(), - } + pub fn first_value(&self) -> Option<&'a str> { + self.name } - pub fn indexed_pair(self) -> Result<Option<(&'a str, &'a str)>, GroupError> { - match self { - Self::Tangle(tag) => Ok(tag.indexed_pair()), - Self::Pocket(mut values) => { - let Some(name) = values.next() else { - return Ok(None); - }; - let name = tag_value_utf8(name)?; - if !TagName::is_indexable_name(name) { - return Ok(None); - } - let Some(value) = values.next() else { - return Ok(None); - }; - Ok(Some((name, tag_value_utf8(value)?))) - } + pub fn indexed_pair(&self) -> Option<(&'a str, &'a str)> { + let name = self.name?; + if !TagName::is_indexable_name(name) { + return None; } + self.value.map(|value| (name, value)) } - pub fn values(self) -> Result<Vec<&'a str>, GroupError> { - match self { - Self::Tangle(tag) => Ok(tag.values().iter().map(String::as_str).collect()), - Self::Pocket(values) => values.map(tag_value_utf8).collect(), + fn from_tangle(tag: &'a Tag) -> Self { + Self { + name: tag.values().first().map(String::as_str), + value: tag.values().get(1).map(String::as_str), } } + + fn from_pocket(mut values: TagsStringIter<'a>) -> Result<Self, GroupError> { + Ok(Self { + name: values.next().map(tag_value_utf8).transpose()?, + value: values.next().map(tag_value_utf8).transpose()?, + }) + } } impl GroupEventView for Event { @@ -76,7 +79,7 @@ impl GroupEventView for Event { F: FnMut(GroupEventTag<'a>) -> Result<(), GroupError>, { for tag in self.unsigned().tags() { - visitor(GroupEventTag::Tangle(tag))?; + visitor(GroupEventTag::from_tangle(tag))?; } Ok(()) } @@ -101,7 +104,7 @@ impl GroupEventView for PocketEvent { { let tags = self.tags().map_err(pocket_tags_error)?; for tag in tags.iter() { - visitor(GroupEventTag::Pocket(tag))?; + visitor(GroupEventTag::from_pocket(tag)?)?; } Ok(()) } @@ -148,6 +151,10 @@ fn pocket_tags_error(error: pocket_types::Error) -> GroupError { ) } +fn event_view_scalar_error(error: String) -> GroupError { + GroupError::internal(error) +} + #[cfg(test)] mod tests { use super::GroupEventView; @@ -168,13 +175,6 @@ mod tests { indexed_pairs(&event), vec![("h".to_owned(), "Farm".to_owned())] ); - assert_eq!( - tag_values(&event), - vec![ - vec!["h".to_owned(), "Farm".to_owned()], - vec!["summary".to_owned(), "Harvest".to_owned()], - ] - ); } #[test] @@ -191,20 +191,13 @@ mod tests { indexed_pairs(pocket), vec![("h".to_owned(), "Farm".to_owned())] ); - assert_eq!( - tag_values(pocket), - vec![ - vec!["h".to_owned(), "Farm".to_owned()], - vec!["summary".to_owned(), "Harvest".to_owned()], - ] - ); } fn indexed_pairs<E: GroupEventView + ?Sized>(event: &E) -> Vec<(String, String)> { let mut pairs = Vec::new(); event .visit_tags(|tag| { - if let Some((name, value)) = tag.indexed_pair()? { + if let Some((name, value)) = tag.indexed_pair() { pairs.push((name.to_owned(), value.to_owned())); } Ok(()) @@ -213,17 +206,6 @@ mod tests { pairs } - fn tag_values<E: GroupEventView + ?Sized>(event: &E) -> Vec<Vec<String>> { - let mut tags = Vec::new(); - event - .visit_tags(|tag| { - tags.push(tag.values()?.into_iter().map(str::to_owned).collect()); - Ok(()) - }) - .expect("visit tags"); - tags - } - fn event() -> Event { Event::new( EventId::new(&"0".repeat(64)).expect("id"), diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs @@ -5,10 +5,10 @@ use crate::{ GroupLifecycleState, GroupProjection, KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, - MemberStatus, RoleDefinition, RoleName, SupportedKinds, require_group_auth_as_author, - resolve_capabilities, + MemberStatus, RoleDefinition, RoleName, SupportedKinds, event_view::GroupEventView, + require_group_auth_as_author, resolve_capabilities, }; -use tangle_protocol::{Event, PublicKeyHex, Tag}; +use tangle_protocol::PublicKeyHex; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct GroupAuthority { @@ -74,7 +74,7 @@ impl<'a> GroupWritePolicy<'a> { pub fn check_event( &self, - event: &Event, + event: &(impl GroupEventView + ?Sized), class: &GroupEventClass, auth: &crate::GroupAuthContext, ) -> Result<GroupWriteDecision, GroupError> { @@ -115,7 +115,7 @@ impl<'a> GroupWritePolicy<'a> { fn check_moderation_event( &self, - event: &Event, + event: &(impl GroupEventView + ?Sized), kind: u32, group_id: &GroupId, ) -> Result<GroupWriteDecision, GroupError> { @@ -129,9 +129,10 @@ impl<'a> GroupWritePolicy<'a> { "invites not enabled", )); } - let required = required_capability(kind, event.unsigned().tags())?; + let actor = event.pubkey()?; + let required = required_capability(kind, event)?; if let Some(required) = required { - self.require_capability(group_id, event.unsigned().pubkey(), required)?; + self.require_capability(group_id, &actor, required)?; } if kind == KIND_GROUP_REMOVE_USER { let target = target_pubkey(event, "p")?; @@ -144,7 +145,7 @@ impl<'a> GroupWritePolicy<'a> { } if kind == KIND_GROUP_EDIT_METADATA && group.metadata().hidden() - && !self.can_read_group(group_id, Some(event.unsigned().pubkey())) + && !self.can_read_group(group_id, Some(&actor)) { return Err(non_enumerating_group_error()); } @@ -153,10 +154,10 @@ impl<'a> GroupWritePolicy<'a> { fn check_create_group( &self, - event: &Event, + event: &(impl GroupEventView + ?Sized), group_id: &GroupId, ) -> Result<GroupWriteDecision, GroupError> { - if !self.authority.is_owner(event.unsigned().pubkey()) { + if !self.authority.is_owner(&event.pubkey()?) { return Err(GroupError::restricted( GroupErrorKind::MissingCapability, "group creation is restricted to relay owners", @@ -179,19 +180,19 @@ impl<'a> GroupWritePolicy<'a> { fn check_normal_event( &self, - event: &Event, + event: &(impl GroupEventView + ?Sized), group_id: &GroupId, ) -> Result<GroupWriteDecision, GroupError> { let group = self.require_active_group(group_id)?; - match event.unsigned().kind().as_u32() { + match event.kind_u32() { KIND_GROUP_JOIN_REQUEST => self.check_join(event, group_id), KIND_GROUP_LEAVE_REQUEST => self.check_leave(event, group_id), _ => { - if group.metadata().restricted() - && !self.can_read_group(group_id, Some(event.unsigned().pubkey())) - { + let actor = event.pubkey()?; + if group.metadata().restricted() && !self.can_read_group(group_id, Some(&actor)) { return Err(non_enumerating_group_error()); } + let kind = event.kind()?; match group.metadata().supported_kinds() { SupportedKinds::UnspecifiedAll => {} SupportedKinds::None => { @@ -201,7 +202,7 @@ impl<'a> GroupWritePolicy<'a> { )); } SupportedKinds::Only(kinds) => { - if !kinds.contains(&event.unsigned().kind()) { + if !kinds.contains(&kind) { return Err(GroupError::restricted( GroupErrorKind::UnsupportedGroupKind, "event kind is not supported by this group", @@ -216,11 +217,11 @@ impl<'a> GroupWritePolicy<'a> { fn check_join( &self, - event: &Event, + event: &(impl GroupEventView + ?Sized), group_id: &GroupId, ) -> Result<GroupWriteDecision, GroupError> { let group = self.require_active_group(group_id)?; - if self.is_current_member(group_id, event.unsigned().pubkey()) { + if self.is_current_member(group_id, &event.pubkey()?) { return Err(GroupError::invalid( GroupErrorKind::DuplicateMember, "group member already exists", @@ -234,11 +235,11 @@ impl<'a> GroupWritePolicy<'a> { fn check_leave( &self, - event: &Event, + event: &(impl GroupEventView + ?Sized), group_id: &GroupId, ) -> Result<GroupWriteDecision, GroupError> { self.require_active_group(group_id)?; - if !self.is_current_member(group_id, event.unsigned().pubkey()) { + if !self.is_current_member(group_id, &event.pubkey()?) { return Err(GroupError::invalid( GroupErrorKind::DuplicateMember, "group member does not exist", @@ -317,10 +318,13 @@ pub fn non_enumerating_group_error() -> GroupError { GroupError::restricted(GroupErrorKind::GroupUnavailable, "group is unavailable") } -fn required_capability(kind: u32, tags: &[Tag]) -> Result<Option<Capability>, GroupError> { +fn required_capability( + kind: u32, + event: &(impl GroupEventView + ?Sized), +) -> Result<Option<Capability>, GroupError> { match kind { KIND_GROUP_PUT_USER => { - if has_role_tag(tags) { + if has_role_tag(event)? { Ok(Some(Capability::ManageRoles)) } else { Ok(Some(Capability::ManageMembers)) @@ -335,19 +339,28 @@ fn required_capability(kind: u32, tags: &[Tag]) -> Result<Option<Capability>, Gr } } -fn has_role_tag(tags: &[Tag]) -> bool { - tags.iter() - .any(|tag| tag.values().first().is_some_and(|name| name == "role")) +fn has_role_tag(event: &(impl GroupEventView + ?Sized)) -> Result<bool, GroupError> { + let mut found = false; + event.visit_tags(|tag| { + if tag.first_value().is_some_and(|name| name == "role") { + found = true; + } + Ok(()) + })?; + Ok(found) } -fn target_pubkey(event: &Event, tag_name: &str) -> Result<PublicKeyHex, GroupError> { - for tag in event.unsigned().tags() { +fn target_pubkey( + event: &(impl GroupEventView + ?Sized), + tag_name: &str, +) -> Result<PublicKeyHex, GroupError> { + let mut found = None; + event.visit_tags(|tag| { if tag - .values() - .first() + .first_value() .is_none_or(|candidate| candidate != tag_name) { - continue; + return Ok(()); } let Some((_, value)) = tag.indexed_pair() else { return Err(GroupError::invalid( @@ -355,17 +368,20 @@ fn target_pubkey(event: &Event, tag_name: &str) -> Result<PublicKeyHex, GroupErr format!("malformed {tag_name} target tag"), )); }; - return PublicKeyHex::new(value).map_err(|reason| { + found = Some(PublicKeyHex::new(value).map_err(|reason| { GroupError::invalid( GroupErrorKind::MalformedTargetTag, format!("malformed {tag_name} target tag: {reason}"), ) - }); - } - Err(GroupError::invalid( - GroupErrorKind::MissingTargetTag, - format!("missing {tag_name} target tag"), - )) + })?); + Ok(()) + })?; + found.ok_or_else(|| { + GroupError::invalid( + GroupErrorKind::MissingTargetTag, + format!("missing {tag_name} target tag"), + ) + }) } #[cfg(test)] diff --git a/crates/tangle_groups/src/read_gate.rs b/crates/tangle_groups/src/read_gate.rs @@ -1,8 +1,9 @@ use crate::{ GroupAuthority, GroupError, GroupEventClass, GroupId, GroupLimitsConfig, GroupProjection, - KIND_GROUP_DELETE_GROUP, MemberStatus, classify_group_event, non_enumerating_group_error, + KIND_GROUP_DELETE_GROUP, MemberStatus, classify_group_event, event_view::GroupEventView, + non_enumerating_group_error, }; -use tangle_protocol::{Event, PublicKeyHex}; +use tangle_protocol::{EventId, PublicKeyHex}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GroupReadDecision { @@ -26,19 +27,20 @@ impl<'a> GroupReadGate<'a> { pub fn screen_event( &self, - event: &Event, + event: &(impl GroupEventView + ?Sized), reader: Option<&PublicKeyHex>, limits: GroupLimitsConfig, ) -> Result<GroupReadDecision, GroupError> { - if self.projection.event_deletion(event.id()).is_some() { + let event_id = event.id()?; + if self.projection.event_deletion(&event_id).is_some() { return Ok(GroupReadDecision::Hidden); } match classify_group_event(event, limits)? { GroupEventClass::NonGroup => Ok(GroupReadDecision::Visible), GroupEventClass::Normal { group_id } => self.screen_normal_event(&group_id, reader), GroupEventClass::Moderation { group_id, .. } => { - if event.unsigned().kind().as_u32() == KIND_GROUP_DELETE_GROUP { - self.screen_delete_group_marker(event, &group_id, reader) + if event.kind_u32() == KIND_GROUP_DELETE_GROUP { + self.screen_delete_group_marker(&event_id, &group_id, reader) } else { self.screen_normal_event(&group_id, reader) } @@ -51,7 +53,7 @@ impl<'a> GroupReadGate<'a> { pub fn require_visible( &self, - event: &Event, + event: &(impl GroupEventView + ?Sized), reader: Option<&PublicKeyHex>, limits: GroupLimitsConfig, ) -> Result<(), GroupError> { @@ -84,7 +86,7 @@ impl<'a> GroupReadGate<'a> { fn screen_delete_group_marker( &self, - event: &Event, + event_id: &EventId, group_id: &GroupId, reader: Option<&PublicKeyHex>, ) -> Result<GroupReadDecision, GroupError> { @@ -94,7 +96,7 @@ impl<'a> GroupReadGate<'a> { if self .projection .tombstone(group_id) - .is_none_or(|tombstone| tombstone.delete_event_id() != event.id()) + .is_none_or(|tombstone| tombstone.delete_event_id() != event_id) { return self.screen_normal_event(group_id, reader); } @@ -147,8 +149,10 @@ mod tests { KIND_GROUP_METADATA, MemberState, MemberStatus, ProjectionOrderTuple, StoreOffset, SupportedKinds, }; + use pocket_types::Event as PocketEvent; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + event_to_value, }; #[test] @@ -171,6 +175,22 @@ mod tests { } #[test] + fn read_gate_screens_pocket_events_through_event_view() { + let owner = pubkey("1"); + let projection = projection_with_group("Farm", metadata(false, false, false, false), owner); + let authority = GroupAuthority::empty(); + let gate = GroupReadGate::new(&projection, &authority); + let event = event(1, vec![h("Farm")]); + let mut buffer = vec![0; 4096]; + + assert_eq!( + gate.screen_event(pocket_event(&event, &mut buffer), None, Default::default()) + .expect("pocket"), + GroupReadDecision::Visible + ); + } + + #[test] fn read_gate_hides_hidden_and_private_group_events_from_non_members() { let owner = pubkey("1"); let member = pubkey("2"); @@ -352,6 +372,12 @@ mod tests { ) } + fn pocket_event<'a>(event: &Event, buffer: &'a mut [u8]) -> &'a PocketEvent { + let raw = event_to_value(event).to_string(); + let (_, pocket) = PocketEvent::from_json(raw.as_bytes(), buffer).expect("pocket"); + pocket + } + fn h(group_id: &str) -> Tag { Tag::from_parts("h", &[group_id]).expect("h") } diff --git a/crates/tangle_groups/src/tags.rs b/crates/tangle_groups/src/tags.rs @@ -1,9 +1,9 @@ use crate::{ GroupLimitsConfig, errors::{GroupError, GroupErrorKind}, + event_view::GroupEventView, ids::GroupId, }; -use tangle_protocol::Tag; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GroupTagName { @@ -40,26 +40,35 @@ impl GroupTag { } } -pub fn has_group_identity_tag(tags: &[Tag]) -> bool { - tags.iter().any(|tag| { - tag.values().first().is_some_and(|name| { - name == GroupTagName::H.as_str() || name == GroupTagName::D.as_str() - }) - }) +pub fn has_group_identity_tag(event: &(impl GroupEventView + ?Sized)) -> Result<bool, GroupError> { + let mut found = false; + event.visit_tags(|tag| { + if tag.first_value().is_some_and(is_group_identity_tag_name) { + found = true; + } + Ok(()) + })?; + Ok(found) } -pub fn group_identity_tag_count(tags: &[Tag]) -> usize { - tags.iter() - .filter(|tag| { - tag.values().first().is_some_and(|name| { - name == GroupTagName::H.as_str() || name == GroupTagName::D.as_str() - }) - }) - .count() +pub fn group_identity_tag_count( + event: &(impl GroupEventView + ?Sized), +) -> Result<usize, GroupError> { + let mut count = 0; + event.visit_tags(|tag| { + if tag.first_value().is_some_and(is_group_identity_tag_name) { + count += 1; + } + Ok(()) + })?; + Ok(count) } -pub fn ensure_group_tag_limit(tags: &[Tag], limits: GroupLimitsConfig) -> Result<(), GroupError> { - let count = group_identity_tag_count(tags); +pub fn ensure_group_tag_limit( + event: &(impl GroupEventView + ?Sized), + limits: GroupLimitsConfig, +) -> Result<(), GroupError> { + let count = group_identity_tag_count(event)?; let max = usize::from(limits.max_group_tags_per_event()); if count > max { return Err(GroupError::invalid( @@ -71,28 +80,24 @@ pub fn ensure_group_tag_limit(tags: &[Tag], limits: GroupLimitsConfig) -> Result } pub fn extract_group_tag( - tags: &[Tag], + event: &(impl GroupEventView + ?Sized), name: GroupTagName, limits: GroupLimitsConfig, ) -> Result<Option<GroupTag>, GroupError> { let mut found: Option<GroupId> = None; - for tag in tags { + event.visit_tags(|tag| { if tag - .values() - .first() + .first_value() .is_none_or(|tag_name| tag_name != name.as_str()) { - continue; + return Ok(()); } - let Some((indexed_name, value)) = tag.indexed_pair() else { + let Some((_, value)) = tag.indexed_pair() else { return Err(GroupError::invalid( GroupErrorKind::MalformedGroupTag, format!("malformed {} group tag", name.as_str()), )); }; - if indexed_name != name.as_str() { - continue; - } let group_id = GroupId::new_with_max_bytes(value, usize::from(limits.max_group_id_bytes()))?; if let Some(first) = &found { @@ -105,16 +110,17 @@ pub fn extract_group_tag( } else { found = Some(group_id); } - } + Ok(()) + })?; Ok(found.map(|group_id| GroupTag::new(name, group_id))) } pub fn require_group_tag( - tags: &[Tag], + event: &(impl GroupEventView + ?Sized), name: GroupTagName, limits: GroupLimitsConfig, ) -> Result<GroupTag, GroupError> { - extract_group_tag(tags, name, limits)?.ok_or_else(|| { + extract_group_tag(event, name, limits)?.ok_or_else(|| { GroupError::invalid( GroupErrorKind::MissingGroupTag, format!("missing {} group tag", name.as_str()), @@ -122,39 +128,45 @@ pub fn require_group_tag( }) } +fn is_group_identity_tag_name(name: &str) -> bool { + name == GroupTagName::H.as_str() || name == GroupTagName::D.as_str() +} + #[cfg(test)] mod tests { use super::{ GroupTagName, extract_group_tag, group_identity_tag_count, has_group_identity_tag, }; use crate::{GroupErrorKind, GroupLimitsConfig}; - use tangle_protocol::Tag; + use tangle_protocol::{ + Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + }; #[test] fn extracts_first_indexed_group_tag_and_allows_exact_duplicates() { - let tags = vec![ + let event = event(vec![ Tag::from_parts("h", &["Farm"]).expect("h"), Tag::from_parts("p", &["a"]).expect("p"), Tag::from_parts("h", &["Farm"]).expect("h"), - ]; + ]); - let group_tag = extract_group_tag(&tags, GroupTagName::H, GroupLimitsConfig::default()) + let group_tag = extract_group_tag(&event, GroupTagName::H, GroupLimitsConfig::default()) .expect("tag") .expect("present"); assert_eq!(group_tag.name(), GroupTagName::H); assert_eq!(group_tag.group_id().as_str(), "Farm"); - assert!(has_group_identity_tag(&tags)); - assert_eq!(group_identity_tag_count(&tags), 2); + assert!(has_group_identity_tag(&event).expect("identity")); + assert_eq!(group_identity_tag_count(&event).expect("count"), 2); } #[test] fn conflicting_duplicate_group_tags_are_rejected() { - let tags = vec![ + let event = event(vec![ Tag::from_parts("h", &["Farm"]).expect("h"), Tag::from_parts("h", &["farm"]).expect("h"), - ]; - let error = extract_group_tag(&tags, GroupTagName::H, GroupLimitsConfig::default()) + ]); + let error = extract_group_tag(&event, GroupTagName::H, GroupLimitsConfig::default()) .expect_err("error"); assert_eq!(error.kind(), GroupErrorKind::ConflictingGroupTag); @@ -163,8 +175,8 @@ mod tests { #[test] fn malformed_group_tags_are_rejected() { - let tags = vec![Tag::new(vec!["h".to_owned()]).expect("tag")]; - let error = extract_group_tag(&tags, GroupTagName::H, GroupLimitsConfig::default()) + let event = event(vec![Tag::new(vec!["h".to_owned()]).expect("tag")]); + let error = extract_group_tag(&event, GroupTagName::H, GroupLimitsConfig::default()) .expect_err("error"); assert_eq!(error.kind(), GroupErrorKind::MalformedGroupTag); @@ -173,17 +185,31 @@ mod tests { #[test] fn group_tag_limit_counts_h_and_d_tags() { - let tags = vec![ + let event = event(vec![ Tag::from_parts("h", &["a"]).expect("h"), Tag::from_parts("d", &["a"]).expect("d"), - ]; + ]); let limits = GroupLimitsConfig::new(128, 1, 512, 1, 1).expect("limits"); assert_eq!( - super::ensure_group_tag_limit(&tags, limits) + super::ensure_group_tag_limit(&event, limits) .expect_err("limit") .message(), "group event has 2 group tags, maximum is 1" ); } + + fn event(tags: Vec<Tag>) -> Event { + Event::new( + EventId::new(&"0".repeat(64)).expect("id"), + UnsignedEvent::new( + PublicKeyHex::new(&"1".repeat(64)).expect("pubkey"), + UnixTimestamp::new(1), + Kind::new(1).expect("kind"), + tags, + "", + ), + SignatureHex::new(&"2".repeat(128)).expect("sig"), + ) + } } diff --git a/crates/tangle_groups/src/write_gate.rs b/crates/tangle_groups/src/write_gate.rs @@ -2,11 +2,12 @@ use crate::{ GroupEventClass, GroupLimitsConfig, classification::classify_group_event, errors::{GroupError, GroupErrorKind}, + event_view::GroupEventView, kinds::{KIND_GROUP_DELETE_EVENT, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER}, tags::ensure_group_tag_limit, }; use std::collections::BTreeSet; -use tangle_protocol::{Event, PublicKeyHex}; +use tangle_protocol::PublicKeyHex; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct GroupAuthContext { @@ -34,10 +35,10 @@ impl GroupAuthContext { } pub fn validate_client_group_event_structure( - event: &Event, + event: &(impl GroupEventView + ?Sized), limits: GroupLimitsConfig, ) -> Result<GroupEventClass, GroupError> { - ensure_group_tag_limit(event.unsigned().tags(), limits)?; + ensure_group_tag_limit(event, limits)?; let class = classify_group_event(event, limits)?; match &class { GroupEventClass::RelayGeneratedSnapshot { .. } => Err(GroupError::blocked( @@ -53,14 +54,14 @@ pub fn validate_client_group_event_structure( } pub fn require_group_auth_as_author( - event: &Event, + event: &(impl GroupEventView + ?Sized), class: &GroupEventClass, auth: &GroupAuthContext, ) -> Result<(), GroupError> { if matches!(class, GroupEventClass::NonGroup) { return Ok(()); } - if auth.contains(event.unsigned().pubkey()) { + if auth.contains(&event.pubkey()?) { return Ok(()); } Err(GroupError::auth_required( @@ -68,7 +69,10 @@ pub fn require_group_auth_as_author( )) } -fn validate_moderation_targets(event: &Event, kind: u32) -> Result<(), GroupError> { +fn validate_moderation_targets( + event: &(impl GroupEventView + ?Sized), + kind: u32, +) -> Result<(), GroupError> { match kind { KIND_GROUP_PUT_USER | KIND_GROUP_REMOVE_USER => require_valid_p_tag(event), KIND_GROUP_DELETE_EVENT => require_indexed_tag_value(event, "e").map(|_| ()), @@ -76,9 +80,9 @@ fn validate_moderation_targets(event: &Event, kind: u32) -> Result<(), GroupErro } } -fn require_valid_p_tag(event: &Event) -> Result<(), GroupError> { +fn require_valid_p_tag(event: &(impl GroupEventView + ?Sized)) -> Result<(), GroupError> { let value = require_indexed_tag_value(event, "p")?; - PublicKeyHex::new(value).map_err(|reason| { + PublicKeyHex::new(&value).map_err(|reason| { GroupError::invalid( GroupErrorKind::MalformedTargetTag, format!("malformed p target tag: {reason}"), @@ -87,10 +91,14 @@ fn require_valid_p_tag(event: &Event) -> Result<(), GroupError> { Ok(()) } -fn require_indexed_tag_value<'a>(event: &'a Event, name: &str) -> Result<&'a str, GroupError> { - for tag in event.unsigned().tags() { - if tag.values().first().is_none_or(|tag_name| tag_name != name) { - continue; +fn require_indexed_tag_value( + event: &(impl GroupEventView + ?Sized), + name: &str, +) -> Result<String, GroupError> { + let mut found = None; + event.visit_tags(|tag| { + if tag.first_value().is_none_or(|tag_name| tag_name != name) { + return Ok(()); } let Some((_, value)) = tag.indexed_pair() else { return Err(GroupError::invalid( @@ -98,12 +106,15 @@ fn require_indexed_tag_value<'a>(event: &'a Event, name: &str) -> Result<&'a str format!("malformed {name} target tag"), )); }; - return Ok(value); - } - Err(GroupError::invalid( - GroupErrorKind::MissingTargetTag, - format!("missing {name} target tag"), - )) + found = Some(value.to_owned()); + Ok(()) + })?; + found.ok_or_else(|| { + GroupError::invalid( + GroupErrorKind::MissingTargetTag, + format!("missing {name} target tag"), + ) + }) } #[cfg(test)] @@ -115,26 +126,34 @@ mod tests { GroupErrorKind, GroupEventClass, GroupLimitsConfig, KIND_GROUP_DELETE_EVENT, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, }; + use pocket_types::Event as PocketEvent; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + event_to_value, }; #[test] fn client_submitted_relay_generated_events_are_rejected() { - let error = validate_client_group_event_structure( - &event( - KIND_GROUP_METADATA, - vec![Tag::from_parts("d", &["Farm"]).expect("d")], - ), - GroupLimitsConfig::default(), - ) - .expect_err("relay generated"); + let event = event( + KIND_GROUP_METADATA, + vec![Tag::from_parts("d", &["Farm"]).expect("d")], + ); + let error = validate_client_group_event_structure(&event, GroupLimitsConfig::default()) + .expect_err("relay generated"); assert_eq!(error.kind(), GroupErrorKind::DirectRelayGeneratedSubmission); assert_eq!( error.prefixed_message(), "blocked: relay-generated group state events cannot be submitted by clients" ); + + let mut buffer = vec![0; 4096]; + let error = validate_client_group_event_structure( + pocket_event(&event, &mut buffer), + GroupLimitsConfig::default(), + ) + .expect_err("pocket relay generated"); + assert_eq!(error.kind(), GroupErrorKind::DirectRelayGeneratedSubmission); } #[test] @@ -269,4 +288,10 @@ mod tests { SignatureHex::new(&"2".repeat(128)).expect("sig"), ) } + + fn pocket_event<'a>(event: &Event, buffer: &'a mut [u8]) -> &'a PocketEvent { + let raw = event_to_value(event).to_string(); + let (_, pocket) = PocketEvent::from_json(raw.as_bytes(), buffer).expect("pocket"); + pocket + } }