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:
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
+ }
}