lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit f6fbab7e1dc5f27528ced08256ee09cc0cbffc77
parent 4a1c0e87de4d04b5e326ce69b456dc71bf13d318
Author: triesap <tyson@radroots.org>
Date:   Fri, 12 Jun 2026 03:10:33 -0700

events_codec: add social production codecs

- add repost, generic repost, report, calendar collection, and RSVP codec modules
- preserve RSVP event revision ids and NIP-99 listing published_at metadata
- validate NIP-65 relay-list entries through the existing RadrootsList codec
- cover production social codecs, listing drafts, and relay-list evidence with serde_json tests

Diffstat:
Mcrates/events/src/calendar.rs | 10++++++++++
Mcrates/events_codec/src/calendar/decode.rs | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/events_codec/src/calendar/encode.rs | 193++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/events_codec/src/lib.rs | 2++
Mcrates/events_codec/src/list/decode.rs | 37++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/src/list/encode.rs | 37++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/src/listing/decode.rs | 8++++++--
Mcrates/events_codec/src/listing/tags.rs | 5++++-
Acrates/events_codec/src/report/decode.rs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/report/encode.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/report/mod.rs | 2++
Acrates/events_codec/src/repost/decode.rs | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/repost/encode.rs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/repost/mod.rs | 2++
Mcrates/events_codec/tests/calendar.rs | 234++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/events_codec/tests/list.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/tests/listing.rs | 29+++++++++++++++++++++++++++--
Acrates/events_codec/tests/report.rs | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/tests/repost.rs | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 2060 insertions(+), 29 deletions(-)

diff --git a/crates/events/src/calendar.rs b/crates/events/src/calendar.rs @@ -110,6 +110,11 @@ pub struct RadrootsCalendarTimeEvent { pub struct RadrootsCalendarEventRsvp { pub d_tag: String, pub event: RadrootsSocialTarget, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub event_id: Option<String>, pub status: RadrootsCalendarEventRsvpStatus, #[cfg_attr( feature = "serde", @@ -214,6 +219,7 @@ mod tests { event_kind: Some(31923), relays: None, }, + event_id: Some("b".repeat(64)), status: RadrootsCalendarEventRsvpStatus::Tentative, free_busy: Some(RadrootsCalendarEventFreeBusy::Busy), note: Some("depends on harvest".to_string()), @@ -221,6 +227,10 @@ mod tests { }; assert_eq!(rsvp.status, RadrootsCalendarEventRsvpStatus::Tentative); + assert_eq!( + rsvp.event_id.as_deref(), + Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + ); assert_eq!(rsvp.free_busy, Some(RadrootsCalendarEventFreeBusy::Busy)); } } diff --git a/crates/events_codec/src/calendar/decode.rs b/crates/events_codec/src/calendar/decode.rs @@ -3,18 +3,29 @@ use alloc::{string::ToString, vec::Vec}; use radroots_events::{ RadrootsNostrEvent, - calendar::{RadrootsCalendarDateEvent, RadrootsCalendarTimeEvent}, - kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT}, - social::RadrootsCalendarDateValue, + calendar::{ + RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp, + RadrootsCalendarTimeEvent, + }, + kinds::{ + KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, KIND_CALENDAR_TIME_EVENT, + }, + social::{ + RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, + RadrootsSocialTarget, + }, tags::{ - TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_IMAGE, TAG_START, TAG_START_TZID, TAG_SUMMARY, - TAG_TITLE, + TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_IMAGE, TAG_START, + TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE, }, }; use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; -use crate::field_helpers::{optional_tag_value, required_tag_value, tag_values}; +use crate::field_helpers::{ + optional_tag_value, parse_address_tag, required_tag_value, tag_values, + validate_lowercase_hex_64_tag, +}; use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; use crate::social_helpers::{ location_from_tags, participants_from_tags, validate_date_tag, validate_end_after_start, @@ -22,6 +33,8 @@ use crate::social_helpers::{ const EXPECTED_DATE_KIND: &str = "31922"; const EXPECTED_TIME_KIND: &str = "31923"; +const EXPECTED_CALENDAR_KIND: &str = "31924"; +const EXPECTED_RSVP_KIND: &str = "31925"; pub fn calendar_date_event_from_event( kind: u32, @@ -104,6 +117,73 @@ pub fn calendar_time_event_from_event( }) } +pub fn calendar_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsCalendar, EventParseError> { + if kind != KIND_CALENDAR { + return Err(EventParseError::InvalidKind { + expected: EXPECTED_CALENDAR_KIND, + got: kind, + }); + } + if !content.is_empty() { + return Err(EventParseError::InvalidJson("content")); + } + let d_tag = required_tag_value(tags, TAG_D)?; + validate_d_tag_tag(&d_tag, TAG_D)?; + let title = required_tag_value(tags, TAG_TITLE)?; + let events = calendar_event_targets_from_tags(tags)?; + if events.is_empty() { + return Err(EventParseError::MissingTag(TAG_A)); + } + Ok(RadrootsCalendar { + d_tag, + title, + events, + summary: optional_tag_value(tags, TAG_SUMMARY)?, + image: optional_tag_value(tags, TAG_IMAGE)?, + }) +} + +pub fn rsvp_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsCalendarEventRsvp, EventParseError> { + if kind != KIND_CALENDAR_EVENT_RSVP { + return Err(EventParseError::InvalidKind { + expected: EXPECTED_RSVP_KIND, + got: kind, + }); + } + let d_tag = required_tag_value(tags, TAG_D)?; + validate_d_tag_tag(&d_tag, TAG_D)?; + let event = calendar_event_target_from_required_tag(tags)?; + let event_id = optional_tag_value(tags, TAG_E)?; + if let Some(event_id) = event_id.as_deref() { + validate_lowercase_hex_64_tag(event_id, TAG_E)?; + } + let status = parse_rsvp_status(&required_tag_value(tags, TAG_STATUS)?)?; + let free_busy = optional_tag_value(tags, TAG_FREE_BUSY)? + .map(|value| parse_free_busy(&value)) + .transpose()?; + Ok(RadrootsCalendarEventRsvp { + d_tag, + event, + event_id, + status, + free_busy, + note: if content.is_empty() { + None + } else { + Some(content.to_string()) + }, + participants: participants_from_tags(tags), + }) +} + pub fn date_data_from_event( id: String, author: String, @@ -140,6 +220,42 @@ pub fn time_data_from_event( )) } +pub fn calendar_data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsCalendar>, EventParseError> { + let calendar = calendar_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + calendar, + )) +} + +pub fn rsvp_data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsCalendarEventRsvp>, EventParseError> { + let rsvp = rsvp_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + rsvp, + )) +} + pub fn date_parsed_from_event( id: String, author: String, @@ -202,6 +318,68 @@ pub fn time_parsed_from_event( }) } +pub fn calendar_parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsCalendar>, EventParseError> { + let data = calendar_data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} + +pub fn rsvp_parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsCalendarEventRsvp>, EventParseError> { + let data = rsvp_data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} + fn parse_required_u64(tags: &[Vec<String>], key: &'static str) -> Result<u64, EventParseError> { required_tag_value(tags, key)? .parse::<u64>() @@ -228,3 +406,74 @@ fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> { Some(values) } } + +fn calendar_event_targets_from_tags( + tags: &[Vec<String>], +) -> Result<Vec<RadrootsSocialTarget>, EventParseError> { + tags.iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) + .map(|tag| calendar_event_target_from_tag(tag)) + .collect() +} + +fn calendar_event_target_from_required_tag( + tags: &[Vec<String>], +) -> Result<RadrootsSocialTarget, EventParseError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) + .ok_or(EventParseError::MissingTag(TAG_A))?; + calendar_event_target_from_tag(tag) +} + +fn calendar_event_target_from_tag(tag: &[String]) -> Result<RadrootsSocialTarget, EventParseError> { + let value = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_A))?; + let address = parse_address_tag(&value, TAG_A)?; + if !is_calendar_event_kind(address.kind) { + return Err(EventParseError::InvalidTag(TAG_A)); + } + Ok(RadrootsSocialTarget::Address { + address: value, + author: Some(address.pubkey), + event_kind: Some(address.kind), + relays: relays_from_tag(tag, 2), + }) +} + +fn relays_from_tag(tag: &[String], start: usize) -> Option<Vec<String>> { + let relays = tag + .iter() + .skip(start) + .filter(|value| !value.trim().is_empty()) + .cloned() + .collect::<Vec<_>>(); + if relays.is_empty() { + None + } else { + Some(relays) + } +} + +fn is_calendar_event_kind(kind: u32) -> bool { + matches!(kind, KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT) +} + +fn parse_rsvp_status(value: &str) -> Result<RadrootsCalendarEventRsvpStatus, EventParseError> { + match value { + "accepted" => Ok(RadrootsCalendarEventRsvpStatus::Accepted), + "declined" => Ok(RadrootsCalendarEventRsvpStatus::Declined), + "tentative" => Ok(RadrootsCalendarEventRsvpStatus::Tentative), + _ => Err(EventParseError::InvalidTag(TAG_STATUS)), + } +} + +fn parse_free_busy(value: &str) -> Result<RadrootsCalendarEventFreeBusy, EventParseError> { + match value { + "free" => Ok(RadrootsCalendarEventFreeBusy::Free), + "busy" => Ok(RadrootsCalendarEventFreeBusy::Busy), + _ => Err(EventParseError::InvalidTag(TAG_FREE_BUSY)), + } +} diff --git a/crates/events_codec/src/calendar/encode.rs b/crates/events_codec/src/calendar/encode.rs @@ -1,18 +1,29 @@ #[cfg(not(feature = "std"))] -use alloc::{string::ToString, vec::Vec}; +use alloc::{format, string::ToString, vec, vec::Vec}; use radroots_events::{ - calendar::{RadrootsCalendarDateEvent, RadrootsCalendarTimeEvent}, - kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT}, + calendar::{ + RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp, + RadrootsCalendarTimeEvent, + }, + kinds::{ + KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, KIND_CALENDAR_TIME_EVENT, + }, + social::{ + RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, RadrootsSocialTarget, + }, tags::{ - TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_IMAGE, TAG_START, TAG_START_TZID, TAG_SUMMARY, - TAG_TITLE, + TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_IMAGE, TAG_START, + TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE, }, }; use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; -use crate::field_helpers::{push_optional_tag, push_tag, validate_non_empty_field}; +use crate::field_helpers::{ + parse_address_tag, push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64, + validate_non_empty_field, +}; use crate::social_helpers::{ push_location_tags, push_participants, validate_date, validate_date_end_after_start, validate_end_after_start, @@ -65,6 +76,50 @@ pub fn calendar_time_event_build_tags( Ok(tags) } +pub fn calendar_collection_build_tags( + calendar: &RadrootsCalendar, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_calendar_collection(calendar)?; + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_D, calendar.d_tag.as_str()); + push_tag(&mut tags, TAG_TITLE, calendar.title.as_str()); + push_optional_tag(&mut tags, TAG_SUMMARY, calendar.summary.as_deref()); + push_optional_tag(&mut tags, TAG_IMAGE, calendar.image.as_deref()); + for event in &calendar.events { + push_calendar_event_address(&mut tags, event, "events")?; + } + Ok(tags) +} + +pub fn rsvp_build_tags( + rsvp: &RadrootsCalendarEventRsvp, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_rsvp(rsvp)?; + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_D, rsvp.d_tag.as_str()); + push_calendar_event_address(&mut tags, &rsvp.event, "event")?; + if let Some(event_id) = rsvp.event_id.as_deref() { + let mut tag = vec![TAG_E.to_string(), event_id.to_string()]; + if let RadrootsSocialTarget::Address { relays, .. } = &rsvp.event { + if let Some(relays) = relays.as_ref() { + tag.extend( + relays + .iter() + .filter(|relay| !relay.trim().is_empty()) + .cloned(), + ); + } + } + tags.push(tag); + } + push_tag(&mut tags, TAG_STATUS, rsvp_status_as_str(&rsvp.status)); + if let Some(free_busy) = rsvp.free_busy.as_ref() { + push_tag(&mut tags, TAG_FREE_BUSY, free_busy_as_str(free_busy)); + } + push_participants(&mut tags, rsvp.participants.as_ref()); + Ok(tags) +} + pub fn date_to_wire_parts( event: &RadrootsCalendarDateEvent, ) -> Result<WireEventParts, EventEncodeError> { @@ -77,6 +132,18 @@ pub fn time_to_wire_parts( time_to_wire_parts_with_kind(event, KIND_CALENDAR_TIME_EVENT) } +pub fn calendar_to_wire_parts( + calendar: &RadrootsCalendar, +) -> Result<WireEventParts, EventEncodeError> { + calendar_to_wire_parts_with_kind(calendar, KIND_CALENDAR) +} + +pub fn rsvp_to_wire_parts( + rsvp: &RadrootsCalendarEventRsvp, +) -> Result<WireEventParts, EventEncodeError> { + rsvp_to_wire_parts_with_kind(rsvp, KIND_CALENDAR_EVENT_RSVP) +} + pub fn date_to_wire_parts_with_kind( event: &RadrootsCalendarDateEvent, kind: u32, @@ -105,6 +172,34 @@ pub fn time_to_wire_parts_with_kind( }) } +pub fn calendar_to_wire_parts_with_kind( + calendar: &RadrootsCalendar, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_CALENDAR { + return Err(EventEncodeError::InvalidKind(kind)); + } + Ok(WireEventParts { + kind, + content: empty_content(), + tags: calendar_collection_build_tags(calendar)?, + }) +} + +pub fn rsvp_to_wire_parts_with_kind( + rsvp: &RadrootsCalendarEventRsvp, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_CALENDAR_EVENT_RSVP { + return Err(EventEncodeError::InvalidKind(kind)); + } + Ok(WireEventParts { + kind, + content: rsvp.note.clone().unwrap_or_default(), + tags: rsvp_build_tags(rsvp)?, + }) +} + fn validate_date_event(event: &RadrootsCalendarDateEvent) -> Result<(), EventEncodeError> { validate_d_tag(&event.d_tag, "d_tag")?; validate_non_empty_field(&event.title, "title")?; @@ -122,3 +217,89 @@ fn validate_time_event(event: &RadrootsCalendarTimeEvent) -> Result<(), EventEnc validate_end_after_start(event.start, event.end, "end")?; Ok(()) } + +fn validate_calendar_collection(calendar: &RadrootsCalendar) -> Result<(), EventEncodeError> { + validate_d_tag(&calendar.d_tag, "d_tag")?; + validate_non_empty_field(&calendar.title, "title")?; + if calendar.events.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("events")); + } + Ok(()) +} + +fn validate_rsvp(rsvp: &RadrootsCalendarEventRsvp) -> Result<(), EventEncodeError> { + validate_d_tag(&rsvp.d_tag, "d_tag")?; + validate_calendar_event_address(&rsvp.event, "event")?; + if let Some(event_id) = rsvp.event_id.as_deref() { + validate_lowercase_hex_64(event_id, "event_id")?; + } + Ok(()) +} + +fn push_calendar_event_address( + tags: &mut Vec<Vec<String>>, + target: &RadrootsSocialTarget, + field: &'static str, +) -> Result<(), EventEncodeError> { + let RadrootsSocialTarget::Address { + address, + event_kind, + relays, + .. + } = target + else { + return Err(EventEncodeError::InvalidField(field)); + }; + let address = + parse_address_tag(address, field).map_err(|_| EventEncodeError::InvalidField(field))?; + if !is_calendar_event_kind(address.kind) { + return Err(EventEncodeError::InvalidField(field)); + } + if let Some(event_kind) = event_kind { + if *event_kind != address.kind { + return Err(EventEncodeError::InvalidField(field)); + } + } + let value = format!("{}:{}:{}", address.kind, address.pubkey, address.d_tag); + if let Some(relays) = relays.as_ref() { + let mut values = Vec::with_capacity(1 + relays.len()); + values.push(value); + values.extend( + relays + .iter() + .filter(|relay| !relay.trim().is_empty()) + .cloned(), + ); + push_tag_values(tags, TAG_A, values); + } else { + push_tag(tags, TAG_A, value); + } + Ok(()) +} + +fn validate_calendar_event_address( + target: &RadrootsSocialTarget, + field: &'static str, +) -> Result<(), EventEncodeError> { + let mut tags = Vec::new(); + push_calendar_event_address(&mut tags, target, field) +} + +fn is_calendar_event_kind(kind: u32) -> bool { + matches!(kind, KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT) +} + +fn rsvp_status_as_str(status: &RadrootsCalendarEventRsvpStatus) -> &'static str { + match status { + RadrootsCalendarEventRsvpStatus::Accepted => "accepted", + RadrootsCalendarEventRsvpStatus::Declined => "declined", + RadrootsCalendarEventRsvpStatus::Tentative => "tentative", + } +} + +fn free_busy_as_str(free_busy: &RadrootsCalendarEventFreeBusy) -> &'static str { + match free_busy { + RadrootsCalendarEventFreeBusy::Free => "free", + RadrootsCalendarEventFreeBusy::Busy => "busy", + } +} diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs @@ -10,6 +10,8 @@ mod field_helpers; pub mod job; pub mod parsed; pub mod profile; +pub mod report; +pub mod repost; mod social_helpers; pub mod tag_builders; pub mod wire; diff --git a/crates/events_codec/src/list/decode.rs b/crates/events_codec/src/list/decode.rs @@ -3,8 +3,9 @@ use alloc::{string::String, vec::Vec}; use radroots_events::{ RadrootsNostrEvent, - kinds::is_nip51_standard_list_kind, + kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_standard_list_kind}, list::{RadrootsList, RadrootsListEntry}, + tags::TAG_R, }; use crate::error::EventParseError; @@ -46,10 +47,44 @@ pub fn list_from_tags( got: kind, }); } + if kind == KIND_LIST_READ_WRITE_RELAYS { + validate_relay_tags(tags)?; + } let entries = list_entries_from_tags(tags)?; Ok(RadrootsList { content, entries }) } +fn validate_relay_tags(tags: &[Vec<String>]) -> Result<(), EventParseError> { + if tags.is_empty() { + return Err(EventParseError::MissingTag(TAG_R)); + } + for tag in tags { + if tag.first().map(|value| value.as_str()) != Some(TAG_R) { + return Err(EventParseError::InvalidTag(TAG_R)); + } + let Some(url) = tag.get(1) else { + return Err(EventParseError::InvalidTag(TAG_R)); + }; + if !is_ws_relay_url(url) { + return Err(EventParseError::InvalidTag(TAG_R)); + } + if tag.len() > 3 { + return Err(EventParseError::InvalidTag(TAG_R)); + } + if let Some(marker) = tag.get(2) { + if marker != "read" && marker != "write" { + return Err(EventParseError::InvalidTag(TAG_R)); + } + } + } + Ok(()) +} + +fn is_ws_relay_url(value: &str) -> bool { + (value.starts_with("wss://") && value.len() > "wss://".len()) + || (value.starts_with("ws://") && value.len() > "ws://".len()) +} + pub fn data_from_event( id: String, author: String, diff --git a/crates/events_codec/src/list/encode.rs b/crates/events_codec/src/list/encode.rs @@ -2,8 +2,9 @@ use alloc::{string::String, vec::Vec}; use radroots_events::{ - kinds::is_nip51_standard_list_kind, + kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_standard_list_kind}, list::{RadrootsList, RadrootsListEntry}, + tags::TAG_R, }; use crate::error::EventEncodeError; @@ -47,6 +48,9 @@ pub fn to_wire_parts_with_kind( if !is_nip51_standard_list_kind(kind) { return Err(EventEncodeError::InvalidKind(kind)); } + if kind == KIND_LIST_READ_WRITE_RELAYS { + validate_relay_entries(&list.entries)?; + } let tags = list_build_tags(list)?; Ok(WireEventParts { kind, @@ -55,6 +59,37 @@ pub fn to_wire_parts_with_kind( }) } +fn validate_relay_entries(entries: &[RadrootsListEntry]) -> Result<(), EventEncodeError> { + if entries.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("relay.entries")); + } + for entry in entries { + if entry.tag != TAG_R { + return Err(EventEncodeError::InvalidField("relay.tag")); + } + let Some(url) = entry.values.first() else { + return Err(EventEncodeError::EmptyRequiredField("relay.url")); + }; + if !is_ws_relay_url(url) { + return Err(EventEncodeError::InvalidField("relay.url")); + } + if entry.values.len() > 2 { + return Err(EventEncodeError::InvalidField("relay.marker")); + } + if let Some(marker) = entry.values.get(1) { + if marker != "read" && marker != "write" { + return Err(EventEncodeError::InvalidField("relay.marker")); + } + } + } + Ok(()) +} + +fn is_ws_relay_url(value: &str) -> bool { + (value.starts_with("wss://") && value.len() > "wss://".len()) + || (value.starts_with("ws://") && value.len() > "ws://".len()) +} + #[cfg(feature = "serde_json")] pub fn list_private_entries_json( entries: &[RadrootsListEntry], diff --git a/crates/events_codec/src/listing/decode.rs b/crates/events_codec/src/listing/decode.rs @@ -21,7 +21,7 @@ use radroots_events::{ }, plot::RadrootsPlotRef, resource_area::RadrootsResourceAreaRef, - tags::TAG_D, + tags::{TAG_D, TAG_PUBLISHED_AT}, }; use crate::d_tag::validate_d_tag_tag; @@ -259,6 +259,7 @@ pub fn listing_from_event_parts( let mut delivery_method: Option<RadrootsListingDeliveryMethod> = None; let mut images: Vec<RadrootsListingImage> = Vec::new(); let mut geohash: Option<String> = None; + let mut published_at: Option<u64> = None; let has_structured_location = tags .iter() @@ -273,6 +274,9 @@ pub fn listing_from_event_parts( "title" => set_if_empty(&mut product.title, tag.get(1)), "category" => set_if_empty(&mut product.category, tag.get(1)), "summary" => set_optional(&mut product.summary, tag.get(1)), + TAG_PUBLISHED_AT => { + published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?); + } "process" => set_optional(&mut product.process, tag.get(1)), "lot" => set_optional(&mut product.lot, tag.get(1)), "location" => { @@ -490,7 +494,7 @@ pub fn listing_from_event_parts( Ok(RadrootsListing { d_tag, - published_at: None, + published_at, farm: farm_ref, product, primary_bin_id, diff --git a/crates/events_codec/src/listing/tags.rs b/crates/events_codec/src/listing/tags.rs @@ -22,7 +22,7 @@ use radroots_events::listing::{ }; use radroots_events::plot::RadrootsPlotRef; use radroots_events::resource_area::RadrootsResourceAreaRef; -use radroots_events::tags::TAG_D; +use radroots_events::tags::{TAG_D, TAG_PUBLISHED_AT}; use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; @@ -133,6 +133,9 @@ pub fn listing_tags_with_options( if let Some(summary) = product.summary.as_deref() { push_tag_value(&mut tags, "summary", summary); } + if let Some(published_at) = listing.published_at { + tags.push(vec![TAG_PUBLISHED_AT.to_string(), published_at.to_string()]); + } if let Some(process) = product.process.as_deref() { push_tag_value(&mut tags, "process", process); } diff --git a/crates/events_codec/src/report/decode.rs b/crates/events_codec/src/report/decode.rs @@ -0,0 +1,213 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::ToString, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + kinds::KIND_REPORT, + report::RadrootsReport, + social::{RadrootsReportFileTarget, RadrootsReportType, RadrootsSocialTarget}, + tags::{TAG_A, TAG_E, TAG_MAGNET, TAG_P, TAG_SERVER, TAG_SHA256}, +}; + +use crate::error::EventParseError; +use crate::field_helpers::{parse_address_tag, required_tag_value, validate_lowercase_hex_64_tag}; +use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; +use crate::social_helpers::first_tag_value; + +pub fn report_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsReport, EventParseError> { + if kind != KIND_REPORT { + return Err(EventParseError::InvalidKind { + expected: "1984", + got: kind, + }); + } + let p_tag = find_tag(tags, TAG_P).ok_or(EventParseError::MissingTag(TAG_P))?; + let reported_pubkey = required_tag_value(tags, TAG_P)?; + let report_type = parse_report_type( + p_tag + .get(2) + .map(|value| value.as_str()) + .ok_or(EventParseError::InvalidTag(TAG_P))?, + TAG_P, + )?; + let event = parse_event_target(tags, &report_type)?; + let file = parse_file_target(tags, &report_type)?; + Ok(RadrootsReport { + reported_pubkey, + report_type, + event, + file, + content: if content.is_empty() { + None + } else { + Some(content.to_string()) + }, + }) +} + +pub fn data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsReport>, EventParseError> { + let report = report_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + report, + )) +} + +pub fn parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsReport>, EventParseError> { + let data = data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} + +fn parse_event_target( + tags: &[Vec<String>], + report_type: &RadrootsReportType, +) -> Result<Option<RadrootsSocialTarget>, EventParseError> { + if let Some(tag) = find_tag(tags, TAG_A) { + validate_optional_target_report_type(tag, 2, report_type, TAG_A)?; + let value = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_A))?; + let address = parse_address_tag(&value, TAG_A)?; + return Ok(Some(RadrootsSocialTarget::Address { + address: value, + author: Some(address.pubkey), + event_kind: Some(address.kind), + relays: relays_from_tag(tag, 3), + })); + } + if let Some(tag) = find_tag(tags, TAG_E) { + validate_optional_target_report_type(tag, 2, report_type, TAG_E)?; + let id = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_E))?; + validate_lowercase_hex_64_tag(&id, TAG_E)?; + return Ok(Some(RadrootsSocialTarget::Event { + id, + author: first_tag_value(tags, TAG_P), + event_kind: None, + relays: relays_from_tag(tag, 3), + })); + } + Ok(None) +} + +fn parse_file_target( + tags: &[Vec<String>], + report_type: &RadrootsReportType, +) -> Result<Option<RadrootsReportFileTarget>, EventParseError> { + let sha256 = if let Some(tag) = find_tag(tags, TAG_SHA256) { + validate_optional_target_report_type(tag, 2, report_type, TAG_SHA256)?; + let value = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_SHA256))?; + validate_lowercase_hex_64_tag(&value, TAG_SHA256)?; + Some(value) + } else { + None + }; + let url = first_tag_value(tags, TAG_SERVER); + let magnet = first_tag_value(tags, TAG_MAGNET); + if sha256.is_none() && url.is_none() && magnet.is_none() { + Ok(None) + } else { + Ok(Some(RadrootsReportFileTarget { + sha256, + url, + magnet, + })) + } +} + +fn validate_optional_target_report_type( + tag: &[String], + index: usize, + expected: &RadrootsReportType, + tag_name: &'static str, +) -> Result<(), EventParseError> { + let Some(value) = tag.get(index) else { + return Ok(()); + }; + if parse_report_type(value, tag_name)? == *expected { + Ok(()) + } else { + Err(EventParseError::InvalidTag(tag_name)) + } +} + +fn parse_report_type( + value: &str, + tag_name: &'static str, +) -> Result<RadrootsReportType, EventParseError> { + match value { + "nudity" => Ok(RadrootsReportType::Nudity), + "malware" => Ok(RadrootsReportType::Malware), + "profanity" => Ok(RadrootsReportType::Profanity), + "illegal" => Ok(RadrootsReportType::Illegal), + "spam" => Ok(RadrootsReportType::Spam), + "impersonation" => Ok(RadrootsReportType::Impersonation), + "other" => Ok(RadrootsReportType::Other), + _ => Err(EventParseError::InvalidTag(tag_name)), + } +} + +fn find_tag<'a>(tags: &'a [Vec<String>], key: &'static str) -> Option<&'a Vec<String>> { + tags.iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(key)) +} + +fn relays_from_tag(tag: &[String], start: usize) -> Option<Vec<String>> { + let relays = tag + .iter() + .skip(start) + .filter(|value| !value.trim().is_empty()) + .cloned() + .collect::<Vec<_>>(); + if relays.is_empty() { + None + } else { + Some(relays) + } +} diff --git a/crates/events_codec/src/report/encode.rs b/crates/events_codec/src/report/encode.rs @@ -0,0 +1,154 @@ +#[cfg(not(feature = "std"))] +use alloc::{format, string::ToString, vec, vec::Vec}; + +use radroots_events::{ + kinds::KIND_REPORT, + report::RadrootsReport, + social::{RadrootsReportFileTarget, RadrootsReportType, RadrootsSocialTarget}, + tags::{TAG_A, TAG_E, TAG_MAGNET, TAG_P, TAG_SERVER, TAG_SHA256}, +}; + +use crate::error::EventEncodeError; +use crate::field_helpers::{ + parse_address_tag, push_tag, validate_lowercase_hex_64, validate_non_empty_field, +}; +use crate::social_helpers::validate_http_url; +use crate::wire::WireEventParts; + +pub fn report_build_tags(report: &RadrootsReport) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_report(report)?; + let report_type = report_type_as_str(&report.report_type); + let mut tags = Vec::new(); + tags.push(vec![ + TAG_P.to_string(), + report.reported_pubkey.clone(), + report_type.to_string(), + ]); + if let Some(event) = report.event.as_ref() { + push_report_event_target(&mut tags, event, report_type)?; + } + if let Some(file) = report.file.as_ref() { + push_report_file_target(&mut tags, file, report_type)?; + } + Ok(tags) +} + +pub fn to_wire_parts(report: &RadrootsReport) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(report, KIND_REPORT) +} + +pub fn to_wire_parts_with_kind( + report: &RadrootsReport, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_REPORT { + return Err(EventEncodeError::InvalidKind(kind)); + } + Ok(WireEventParts { + kind, + content: report.content.clone().unwrap_or_default(), + tags: report_build_tags(report)?, + }) +} + +fn validate_report(report: &RadrootsReport) -> Result<(), EventEncodeError> { + validate_non_empty_field(&report.reported_pubkey, "reported_pubkey")?; + if let Some(file) = report.file.as_ref() { + validate_file_target(file)?; + } + Ok(()) +} + +fn push_report_event_target( + tags: &mut Vec<Vec<String>>, + target: &RadrootsSocialTarget, + report_type: &'static str, +) -> Result<(), EventEncodeError> { + match target { + RadrootsSocialTarget::Event { id, relays, .. } => { + validate_lowercase_hex_64(id, "event.id")?; + let mut tag = vec![TAG_E.to_string(), id.clone(), report_type.to_string()]; + if let Some(relays) = relays.as_ref() { + tag.extend( + relays + .iter() + .filter(|relay| !relay.trim().is_empty()) + .cloned(), + ); + } + tags.push(tag); + Ok(()) + } + RadrootsSocialTarget::Address { + address, relays, .. + } => { + let address = parse_address_tag(address, "event.address") + .map_err(|_| EventEncodeError::InvalidField("event.address"))?; + let mut tag = vec![ + TAG_A.to_string(), + format!("{}:{}:{}", address.kind, address.pubkey, address.d_tag), + report_type.to_string(), + ]; + if let Some(relays) = relays.as_ref() { + tag.extend( + relays + .iter() + .filter(|relay| !relay.trim().is_empty()) + .cloned(), + ); + } + tags.push(tag); + Ok(()) + } + RadrootsSocialTarget::External { .. } => Err(EventEncodeError::InvalidField("event")), + } +} + +fn push_report_file_target( + tags: &mut Vec<Vec<String>>, + file: &RadrootsReportFileTarget, + report_type: &'static str, +) -> Result<(), EventEncodeError> { + if let Some(hash) = file.sha256.as_deref() { + tags.push(vec![ + TAG_SHA256.to_string(), + hash.to_string(), + report_type.to_string(), + ]); + } + if let Some(url) = file.url.as_deref() { + push_tag(tags, TAG_SERVER, url); + } + if let Some(magnet) = file.magnet.as_deref() { + push_tag(tags, TAG_MAGNET, magnet); + } + Ok(()) +} + +fn validate_file_target(file: &RadrootsReportFileTarget) -> Result<(), EventEncodeError> { + if file.sha256.is_none() && file.url.is_none() && file.magnet.is_none() { + return Err(EventEncodeError::EmptyRequiredField("file")); + } + if let Some(hash) = file.sha256.as_deref() { + validate_lowercase_hex_64(hash, "file.sha256")?; + } + if let Some(url) = file.url.as_deref() { + validate_http_url(url, "file.url")?; + } + if let Some(magnet) = file.magnet.as_deref() { + validate_non_empty_field(magnet, "file.magnet")?; + } + Ok(()) +} + +fn report_type_as_str(report_type: &RadrootsReportType) -> &'static str { + match report_type { + RadrootsReportType::Nudity => "nudity", + RadrootsReportType::Malware => "malware", + RadrootsReportType::Profanity => "profanity", + RadrootsReportType::Illegal => "illegal", + RadrootsReportType::Spam => "spam", + RadrootsReportType::Impersonation => "impersonation", + RadrootsReportType::Other => "other", + } +} diff --git a/crates/events_codec/src/report/mod.rs b/crates/events_codec/src/report/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/crates/events_codec/src/repost/decode.rs b/crates/events_codec/src/repost/decode.rs @@ -0,0 +1,222 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::ToString, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + kinds::{KIND_GENERIC_REPOST, KIND_POST, KIND_REPOST}, + repost::{RadrootsGenericRepost, RadrootsRepost}, + social::RadrootsSocialTarget, + tags::{TAG_A, TAG_E, TAG_K, TAG_P}, +}; + +use crate::error::EventParseError; +use crate::field_helpers::{parse_address_tag, required_tag_value, validate_lowercase_hex_64_tag}; +use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; +use crate::social_helpers::first_tag_value; + +pub fn repost_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsRepost, EventParseError> { + if kind != KIND_REPOST { + return Err(EventParseError::InvalidKind { + expected: "6", + got: kind, + }); + } + let event_tag = find_tag(tags, TAG_E).ok_or(EventParseError::MissingTag(TAG_E))?; + let id = event_tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_E))?; + validate_lowercase_hex_64_tag(&id, TAG_E)?; + Ok(RadrootsRepost { + target: RadrootsSocialTarget::Event { + id, + author: first_tag_value(tags, TAG_P), + event_kind: Some(KIND_POST), + relays: relays_from_tag(event_tag, 2), + }, + content: optional_content(content), + }) +} + +pub fn generic_repost_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGenericRepost, EventParseError> { + if kind != KIND_GENERIC_REPOST { + return Err(EventParseError::InvalidKind { + expected: "16", + got: kind, + }); + } + let target_kind = required_tag_value(tags, TAG_K)? + .parse::<u32>() + .map_err(|err| EventParseError::InvalidNumber(TAG_K, err))?; + if target_kind == KIND_POST { + return Err(EventParseError::InvalidTag(TAG_K)); + } + let target = if let Some(tag) = find_tag(tags, TAG_A) { + let value = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_A))?; + let address = parse_address_tag(&value, TAG_A)?; + if address.kind != target_kind { + return Err(EventParseError::InvalidTag(TAG_A)); + } + RadrootsSocialTarget::Address { + address: value, + author: Some(address.pubkey), + event_kind: Some(target_kind), + relays: relays_from_tag(tag, 2), + } + } else if let Some(tag) = find_tag(tags, TAG_E) { + let id = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_E))?; + validate_lowercase_hex_64_tag(&id, TAG_E)?; + RadrootsSocialTarget::Event { + id, + author: first_tag_value(tags, TAG_P), + event_kind: Some(target_kind), + relays: relays_from_tag(tag, 2), + } + } else { + return Err(EventParseError::MissingTag(TAG_E)); + }; + Ok(RadrootsGenericRepost { + target, + target_kind, + content: optional_content(content), + }) +} + +pub fn repost_data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsRepost>, EventParseError> { + let repost = repost_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + repost, + )) +} + +pub fn generic_repost_data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsGenericRepost>, EventParseError> { + let repost = generic_repost_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + repost, + )) +} + +pub fn repost_parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsRepost>, EventParseError> { + let data = repost_data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} + +pub fn generic_repost_parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsGenericRepost>, EventParseError> { + let data = generic_repost_data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} + +fn find_tag<'a>(tags: &'a [Vec<String>], key: &'static str) -> Option<&'a Vec<String>> { + tags.iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(key)) +} + +fn relays_from_tag(tag: &[String], start: usize) -> Option<Vec<String>> { + let relays = tag + .iter() + .skip(start) + .filter(|value| !value.trim().is_empty()) + .cloned() + .collect::<Vec<_>>(); + if relays.is_empty() { + None + } else { + Some(relays) + } +} + +fn optional_content(content: &str) -> Option<String> { + if content.is_empty() { + None + } else { + Some(content.to_string()) + } +} diff --git a/crates/events_codec/src/repost/encode.rs b/crates/events_codec/src/repost/encode.rs @@ -0,0 +1,186 @@ +#[cfg(not(feature = "std"))] +use alloc::{format, string::ToString, vec, vec::Vec}; + +use radroots_events::{ + kinds::{KIND_GENERIC_REPOST, KIND_POST, KIND_REPOST}, + repost::{RadrootsGenericRepost, RadrootsRepost}, + social::RadrootsSocialTarget, + tags::{TAG_A, TAG_E, TAG_K, TAG_P}, +}; + +use crate::error::EventEncodeError; +use crate::field_helpers::{ + parse_address_tag, push_tag, validate_lowercase_hex_64, validate_non_empty_field, +}; +use crate::wire::WireEventParts; + +pub fn repost_build_tags(repost: &RadrootsRepost) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = Vec::new(); + push_event_target(&mut tags, &repost.target, KIND_POST, "target")?; + Ok(tags) +} + +pub fn generic_repost_build_tags( + repost: &RadrootsGenericRepost, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_generic_target_kind(repost.target_kind)?; + let mut tags = Vec::new(); + push_generic_target(&mut tags, &repost.target, repost.target_kind)?; + push_tag(&mut tags, TAG_K, repost.target_kind.to_string()); + Ok(tags) +} + +pub fn repost_to_wire_parts(repost: &RadrootsRepost) -> Result<WireEventParts, EventEncodeError> { + repost_to_wire_parts_with_kind(repost, KIND_REPOST) +} + +pub fn generic_repost_to_wire_parts( + repost: &RadrootsGenericRepost, +) -> Result<WireEventParts, EventEncodeError> { + generic_repost_to_wire_parts_with_kind(repost, KIND_GENERIC_REPOST) +} + +pub fn repost_to_wire_parts_with_kind( + repost: &RadrootsRepost, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_REPOST { + return Err(EventEncodeError::InvalidKind(kind)); + } + Ok(WireEventParts { + kind, + content: repost.content.clone().unwrap_or_default(), + tags: repost_build_tags(repost)?, + }) +} + +pub fn generic_repost_to_wire_parts_with_kind( + repost: &RadrootsGenericRepost, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_GENERIC_REPOST { + return Err(EventEncodeError::InvalidKind(kind)); + } + Ok(WireEventParts { + kind, + content: repost.content.clone().unwrap_or_default(), + tags: generic_repost_build_tags(repost)?, + }) +} + +fn push_event_target( + tags: &mut Vec<Vec<String>>, + target: &RadrootsSocialTarget, + expected_kind: u32, + field: &'static str, +) -> Result<(), EventEncodeError> { + let RadrootsSocialTarget::Event { + id, + author, + event_kind, + relays, + } = target + else { + return Err(EventEncodeError::InvalidField(field)); + }; + validate_lowercase_hex_64(id, "target.id")?; + validate_event_kind(*event_kind, expected_kind)?; + let mut event_tag = vec![TAG_E.to_string(), id.clone()]; + if let Some(relays) = relays.as_ref() { + event_tag.extend( + relays + .iter() + .filter(|relay| !relay.trim().is_empty()) + .cloned(), + ); + } + tags.push(event_tag); + if let Some(author) = author.as_deref() { + validate_non_empty_field(author, "target.author")?; + push_tag(tags, TAG_P, author); + } + Ok(()) +} + +fn push_generic_target( + tags: &mut Vec<Vec<String>>, + target: &RadrootsSocialTarget, + expected_kind: u32, +) -> Result<(), EventEncodeError> { + match target { + RadrootsSocialTarget::Event { + id, + author, + event_kind, + relays, + } => { + validate_lowercase_hex_64(id, "target.id")?; + validate_event_kind(*event_kind, expected_kind)?; + let mut event_tag = vec![TAG_E.to_string(), id.clone()]; + if let Some(relays) = relays.as_ref() { + event_tag.extend( + relays + .iter() + .filter(|relay| !relay.trim().is_empty()) + .cloned(), + ); + } + tags.push(event_tag); + if let Some(author) = author.as_deref() { + validate_non_empty_field(author, "target.author")?; + push_tag(tags, TAG_P, author); + } + Ok(()) + } + RadrootsSocialTarget::Address { + address, + author, + event_kind, + relays, + } => { + let address = parse_address_tag(address, "target.address") + .map_err(|_| EventEncodeError::InvalidField("target.address"))?; + if address.kind != expected_kind { + return Err(EventEncodeError::InvalidField("target_kind")); + } + validate_event_kind(*event_kind, expected_kind)?; + let mut tag = vec![ + TAG_A.to_string(), + format!("{}:{}:{}", address.kind, address.pubkey, address.d_tag), + ]; + if let Some(relays) = relays.as_ref() { + tag.extend( + relays + .iter() + .filter(|relay| !relay.trim().is_empty()) + .cloned(), + ); + } + tags.push(tag); + if let Some(author) = author.as_deref() { + validate_non_empty_field(author, "target.author")?; + } + Ok(()) + } + RadrootsSocialTarget::External { .. } => Err(EventEncodeError::InvalidField("target")), + } +} + +fn validate_event_kind( + event_kind: Option<u32>, + expected_kind: u32, +) -> Result<(), EventEncodeError> { + if event_kind == Some(expected_kind) { + Ok(()) + } else { + Err(EventEncodeError::InvalidField("target_kind")) + } +} + +fn validate_generic_target_kind(target_kind: u32) -> Result<(), EventEncodeError> { + if target_kind == KIND_POST { + Err(EventEncodeError::InvalidField("target_kind")) + } else { + Ok(()) + } +} diff --git a/crates/events_codec/src/repost/mod.rs b/crates/events_codec/src/repost/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/crates/events_codec/tests/calendar.rs b/crates/events_codec/tests/calendar.rs @@ -1,29 +1,46 @@ #![cfg(feature = "serde_json")] use radroots_events::{ - calendar::{RadrootsCalendarDateEvent, RadrootsCalendarTimeEvent}, - kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_POST}, - social::{RadrootsCalendarDateValue, RadrootsCalendarParticipant, RadrootsSocialLocation}, + calendar::{ + RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp, + RadrootsCalendarTimeEvent, + }, + kinds::{ + KIND_ARTICLE, KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, + KIND_CALENDAR_TIME_EVENT, KIND_POST, + }, + social::{ + RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, + RadrootsCalendarParticipant, RadrootsSocialLocation, RadrootsSocialTarget, + }, tags::{ - TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_G, TAG_IMAGE, TAG_LOCATION, TAG_P, TAG_START, - TAG_START_TZID, TAG_SUMMARY, TAG_TITLE, + TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_G, TAG_IMAGE, + TAG_LOCATION, TAG_P, TAG_START, TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE, }, }; use radroots_events_codec::{ calendar::{ decode::{ - calendar_date_event_from_event, calendar_time_event_from_event, date_data_from_event, - date_parsed_from_event, time_data_from_event, time_parsed_from_event, + calendar_data_from_event, calendar_date_event_from_event, calendar_from_event, + calendar_parsed_from_event, calendar_time_event_from_event, date_data_from_event, + date_parsed_from_event, rsvp_data_from_event, rsvp_from_event, rsvp_parsed_from_event, + time_data_from_event, time_parsed_from_event, }, encode::{ - calendar_date_event_build_tags, calendar_time_event_build_tags, date_to_wire_parts, - date_to_wire_parts_with_kind, time_to_wire_parts, time_to_wire_parts_with_kind, + calendar_collection_build_tags, calendar_date_event_build_tags, + calendar_time_event_build_tags, calendar_to_wire_parts, + calendar_to_wire_parts_with_kind, date_to_wire_parts, date_to_wire_parts_with_kind, + rsvp_build_tags, rsvp_to_wire_parts, rsvp_to_wire_parts_with_kind, time_to_wire_parts, + time_to_wire_parts_with_kind, }, }, error::{EventEncodeError, EventParseError}, }; const VALID_D_TAG: &str = "CCCCCCCCCCCCCCCCCCCCCA"; +const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const EVENT_AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const EVENT_D_TAG: &str = "EEEEEEEEEEEEEEEEEEEEEA"; fn sample_date_event() -> RadrootsCalendarDateEvent { RadrootsCalendarDateEvent { @@ -70,6 +87,42 @@ fn sample_time_event() -> RadrootsCalendarTimeEvent { } } +fn sample_calendar_collection() -> RadrootsCalendar { + RadrootsCalendar { + d_tag: VALID_D_TAG.to_string(), + title: "Farm calendar".to_string(), + events: vec![RadrootsSocialTarget::Address { + address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"), + author: Some(EVENT_AUTHOR.to_string()), + event_kind: Some(KIND_CALENDAR_TIME_EVENT), + relays: Some(vec!["wss://relay.example.test".to_string()]), + }], + summary: Some("CSA and harvest schedule".to_string()), + image: Some("https://media.example.test/calendar.jpg".to_string()), + } +} + +fn sample_rsvp() -> RadrootsCalendarEventRsvp { + RadrootsCalendarEventRsvp { + d_tag: VALID_D_TAG.to_string(), + event: RadrootsSocialTarget::Address { + address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"), + author: Some(EVENT_AUTHOR.to_string()), + event_kind: Some(KIND_CALENDAR_TIME_EVENT), + relays: Some(vec!["wss://relay.example.test".to_string()]), + }, + event_id: Some(EVENT_ID.to_string()), + status: RadrootsCalendarEventRsvpStatus::Accepted, + free_busy: Some(RadrootsCalendarEventFreeBusy::Busy), + note: Some("I can attend after harvest".to_string()), + participants: Some(vec![RadrootsCalendarParticipant { + pubkey: "crew_pubkey".to_string(), + relay: None, + role: Some("participant".to_string()), + }]), + } +} + fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool { tags.iter().any(|tag| { tag.first().map(|entry| entry.as_str()) == Some(key) @@ -141,6 +194,60 @@ fn calendar_time_event_to_wire_parts_roundtrips_tags() { } #[test] +fn calendar_collection_to_wire_parts_roundtrips_event_addresses() { + let calendar = sample_calendar_collection(); + let parts = calendar_to_wire_parts(&calendar).unwrap(); + + assert_eq!(parts.kind, KIND_CALENDAR); + assert!(parts.content.is_empty()); + assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG)); + assert!(has_tag(&parts.tags, TAG_TITLE, "Farm calendar")); + assert!(has_tag( + &parts.tags, + TAG_A, + format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str() + )); + + let decoded = calendar_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.d_tag, VALID_D_TAG); + assert_eq!(decoded.title, "Farm calendar"); + assert_eq!(decoded.events.len(), 1); + assert!(matches!( + decoded.events[0], + RadrootsSocialTarget::Address { + event_kind: Some(KIND_CALENDAR_TIME_EVENT), + .. + } + )); +} + +#[test] +fn calendar_rsvp_to_wire_parts_roundtrips_status_event_id_and_participants() { + let rsvp = sample_rsvp(); + let parts = rsvp_to_wire_parts(&rsvp).unwrap(); + + assert_eq!(parts.kind, KIND_CALENDAR_EVENT_RSVP); + assert_eq!(parts.content, "I can attend after harvest"); + assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG)); + assert!(has_tag( + &parts.tags, + TAG_A, + format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str() + )); + assert!(has_tag(&parts.tags, TAG_E, EVENT_ID)); + assert!(has_tag(&parts.tags, TAG_STATUS, "accepted")); + assert!(has_tag(&parts.tags, TAG_FREE_BUSY, "busy")); + assert!(has_tag(&parts.tags, TAG_P, "crew_pubkey")); + + let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.event_id.as_deref(), Some(EVENT_ID)); + assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Accepted); + assert_eq!(decoded.free_busy, Some(RadrootsCalendarEventFreeBusy::Busy)); + assert_eq!(decoded.note.as_deref(), Some("I can attend after harvest")); + assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1)); +} + +#[test] fn calendar_codecs_reject_wrong_kind_invalid_dates_and_nonempty_content() { assert!(matches!( date_to_wire_parts_with_kind(&sample_date_event(), KIND_POST), @@ -193,6 +300,63 @@ fn calendar_codecs_reject_wrong_kind_invalid_dates_and_nonempty_content() { } #[test] +fn calendar_collection_and_rsvp_reject_missing_or_invalid_required_tags() { + assert!(matches!( + calendar_to_wire_parts_with_kind(&sample_calendar_collection(), KIND_POST), + Err(EventEncodeError::InvalidKind(KIND_POST)) + )); + assert!(matches!( + rsvp_to_wire_parts_with_kind(&sample_rsvp(), KIND_POST), + Err(EventEncodeError::InvalidKind(KIND_POST)) + )); + + let mut calendar = sample_calendar_collection(); + calendar.events.clear(); + assert!(matches!( + calendar_collection_build_tags(&calendar), + Err(EventEncodeError::EmptyRequiredField("events")) + )); + + let mut rsvp = sample_rsvp(); + if let RadrootsSocialTarget::Address { event_kind, .. } = &mut rsvp.event { + *event_kind = Some(KIND_ARTICLE); + } + assert!(matches!( + rsvp_build_tags(&rsvp), + Err(EventEncodeError::InvalidField("event")) + )); + + let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap(); + tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_A)); + assert!(matches!( + calendar_from_event(KIND_CALENDAR, &tags, ""), + Err(EventParseError::MissingTag(TAG_A)) + )); + + let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap(); + let status = tags + .iter_mut() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_STATUS)) + .expect("status tag"); + status[1] = "maybe".to_string(); + assert!(matches!( + rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""), + Err(EventParseError::InvalidTag(TAG_STATUS)) + )); + + let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap(); + let free_busy = tags + .iter_mut() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_FREE_BUSY)) + .expect("fb tag"); + free_busy[1] = "unknown".to_string(); + assert!(matches!( + rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""), + Err(EventParseError::InvalidTag(TAG_FREE_BUSY)) + )); +} + +#[test] fn calendar_wrappers_preserve_event_metadata() { let date = sample_date_event(); let date_parts = date_to_wire_parts(&date).unwrap(); @@ -245,4 +409,56 @@ fn calendar_wrappers_preserve_event_metadata() { ) .unwrap(); assert_eq!(time_parsed.event.created_at, 8); + + let calendar = sample_calendar_collection(); + let calendar_parts = calendar_to_wire_parts(&calendar).unwrap(); + let calendar_data = calendar_data_from_event( + "calendar_id".to_string(), + "author".to_string(), + 9, + calendar_parts.kind, + calendar_parts.content.clone(), + calendar_parts.tags.clone(), + ) + .unwrap(); + assert_eq!(calendar_data.kind, KIND_CALENDAR); + assert_eq!(calendar_data.data.title, "Farm calendar"); + + let calendar_parsed = calendar_parsed_from_event( + "calendar_id".to_string(), + "author".to_string(), + 9, + calendar_parts.kind, + calendar_parts.content, + calendar_parts.tags, + "sig".to_string(), + ) + .unwrap(); + assert_eq!(calendar_parsed.event.sig, "sig"); + + let rsvp = sample_rsvp(); + let rsvp_parts = rsvp_to_wire_parts(&rsvp).unwrap(); + let rsvp_data = rsvp_data_from_event( + "rsvp_id".to_string(), + "author".to_string(), + 10, + rsvp_parts.kind, + rsvp_parts.content.clone(), + rsvp_parts.tags.clone(), + ) + .unwrap(); + assert_eq!(rsvp_data.kind, KIND_CALENDAR_EVENT_RSVP); + assert_eq!(rsvp_data.data.event_id.as_deref(), Some(EVENT_ID)); + + let rsvp_parsed = rsvp_parsed_from_event( + "rsvp_id".to_string(), + "author".to_string(), + 10, + rsvp_parts.kind, + rsvp_parts.content, + rsvp_parts.tags, + "sig".to_string(), + ) + .unwrap(); + assert_eq!(rsvp_parsed.event.created_at, 10); } diff --git a/crates/events_codec/tests/list.rs b/crates/events_codec/tests/list.rs @@ -1,6 +1,7 @@ use radroots_events::{ - kinds::{KIND_LIST_MUTE, KIND_POST}, + kinds::{KIND_LIST_MUTE, KIND_LIST_READ_WRITE_RELAYS, KIND_POST}, list::{RadrootsList, RadrootsListEntry}, + tags::TAG_R, }; use radroots_events_codec::error::{EventEncodeError, EventParseError}; use radroots_events_codec::list::decode::{ @@ -172,3 +173,98 @@ fn list_index_from_event_propagates_parse_errors() { } )); } + +#[test] +fn relay_list_kind_roundtrips_nip65_r_tags() { + let list = RadrootsList { + content: String::new(), + entries: vec![ + RadrootsListEntry { + tag: TAG_R.to_string(), + values: vec!["wss://read.example.test".to_string(), "read".to_string()], + }, + RadrootsListEntry { + tag: TAG_R.to_string(), + values: vec!["wss://write.example.test".to_string(), "write".to_string()], + }, + RadrootsListEntry { + tag: TAG_R.to_string(), + values: vec!["wss://both.example.test".to_string()], + }, + ], + }; + + let parts = to_wire_parts_with_kind(&list, KIND_LIST_READ_WRITE_RELAYS).unwrap(); + assert_eq!(parts.kind, KIND_LIST_READ_WRITE_RELAYS); + assert!(parts.content.is_empty()); + assert_eq!(parts.tags.len(), 3); + + let decoded = list_from_tags(parts.kind, parts.content, &parts.tags).unwrap(); + assert_eq!(decoded.entries.len(), 3); + assert_eq!(decoded.entries[0].values[1], "read"); + assert_eq!(decoded.entries[1].values[1], "write"); + assert_eq!(decoded.entries[2].values.len(), 1); +} + +#[test] +fn relay_list_kind_validates_url_shape_and_markers() { + let invalid_url = RadrootsList { + content: String::new(), + entries: vec![RadrootsListEntry { + tag: TAG_R.to_string(), + values: vec!["https://relay.example.test".to_string()], + }], + }; + assert!(matches!( + to_wire_parts_with_kind(&invalid_url, KIND_LIST_READ_WRITE_RELAYS), + Err(EventEncodeError::InvalidField("relay.url")) + )); + assert!(matches!( + list_from_tags( + KIND_LIST_READ_WRITE_RELAYS, + String::new(), + &[vec![ + TAG_R.to_string(), + "https://relay.example.test".to_string() + ]] + ), + Err(EventParseError::InvalidTag(TAG_R)) + )); + + let invalid_marker = RadrootsList { + content: String::new(), + entries: vec![RadrootsListEntry { + tag: TAG_R.to_string(), + values: vec!["wss://relay.example.test".to_string(), "both".to_string()], + }], + }; + assert!(matches!( + to_wire_parts_with_kind(&invalid_marker, KIND_LIST_READ_WRITE_RELAYS), + Err(EventEncodeError::InvalidField("relay.marker")) + )); + assert!(matches!( + list_from_tags( + KIND_LIST_READ_WRITE_RELAYS, + String::new(), + &[vec![ + TAG_R.to_string(), + "wss://relay.example.test".to_string(), + "both".to_string() + ]] + ), + Err(EventParseError::InvalidTag(TAG_R)) + )); + + let empty = RadrootsList { + content: String::new(), + entries: Vec::new(), + }; + assert!(matches!( + to_wire_parts_with_kind(&empty, KIND_LIST_READ_WRITE_RELAYS), + Err(EventEncodeError::EmptyRequiredField("relay.entries")) + )); + assert!(matches!( + list_from_tags(KIND_LIST_READ_WRITE_RELAYS, String::new(), &[]), + Err(EventParseError::MissingTag(TAG_R)) + )); +} diff --git a/crates/events_codec/tests/listing.rs b/crates/events_codec/tests/listing.rs @@ -5,7 +5,7 @@ use radroots_core::{ RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; -use radroots_events::tags::TAG_D; +use radroots_events::tags::{TAG_D, TAG_PUBLISHED_AT}; use radroots_events::{ farm::RadrootsFarmRef, kinds::{KIND_LISTING, KIND_LISTING_DRAFT, KIND_POST}, @@ -222,13 +222,38 @@ fn listing_from_event_rejects_wrong_kind() { #[test] fn draft_listing_roundtrip_from_event() { - let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); + listing.published_at = Some(1_781_895_600); let parts = to_wire_parts_with_kind(&listing, KIND_LISTING_DRAFT).unwrap(); let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); assert_eq!(parts.kind, KIND_LISTING_DRAFT); assert_eq!(parts.content, "# Widget"); assert_eq!(decoded.d_tag, listing.d_tag); + assert_eq!(decoded.published_at, Some(1_781_895_600)); +} + +#[test] +fn listing_roundtrips_published_at_for_active_and_rejects_bad_value() { + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + listing.published_at = Some(1_781_895_600); + let parts = to_wire_parts_with_kind(&listing, KIND_LISTING).unwrap(); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT) + && tag.get(1).map(|value| value.as_str()) == Some("1781895600") + })); + + let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.published_at, Some(1_781_895_600)); + + let mut tags = parts.tags; + let published_at = tags + .iter_mut() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT)) + .expect("published_at tag"); + published_at[1] = "bad".to_string(); + let err = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag(TAG_PUBLISHED_AT))); } #[test] diff --git a/crates/events_codec/tests/report.rs b/crates/events_codec/tests/report.rs @@ -0,0 +1,194 @@ +#![cfg(feature = "serde_json")] + +use radroots_events::{ + kinds::{KIND_POST, KIND_REPORT}, + report::RadrootsReport, + social::{RadrootsReportFileTarget, RadrootsReportType, RadrootsSocialTarget}, + tags::{TAG_E, TAG_MAGNET, TAG_P, TAG_SERVER, TAG_SHA256}, +}; +use radroots_events_codec::{ + error::{EventEncodeError, EventParseError}, + report::{ + decode::{data_from_event, parsed_from_event, report_from_event}, + encode::{report_build_tags, to_wire_parts, to_wire_parts_with_kind}, + }, +}; + +const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const REPORTED: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const FILE_HASH: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +fn profile_report() -> RadrootsReport { + RadrootsReport { + reported_pubkey: REPORTED.to_string(), + report_type: RadrootsReportType::Spam, + event: None, + file: None, + content: None, + } +} + +fn event_report() -> RadrootsReport { + RadrootsReport { + reported_pubkey: REPORTED.to_string(), + report_type: RadrootsReportType::Illegal, + event: Some(RadrootsSocialTarget::Event { + id: EVENT_ID.to_string(), + author: Some(REPORTED.to_string()), + event_kind: Some(KIND_POST), + relays: Some(vec!["wss://relay.example.test".to_string()]), + }), + file: None, + content: Some("Contains prohibited listing text".to_string()), + } +} + +fn file_report() -> RadrootsReport { + RadrootsReport { + reported_pubkey: REPORTED.to_string(), + report_type: RadrootsReportType::Malware, + event: None, + file: Some(RadrootsReportFileTarget { + sha256: Some(FILE_HASH.to_string()), + url: Some("https://media.example.test/blob".to_string()), + magnet: Some("magnet:?xt=urn:btih:example".to_string()), + }), + content: None, + } +} + +fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool { + tags.iter().any(|tag| { + tag.first().map(|entry| entry.as_str()) == Some(key) + && tag.get(1).map(|entry| entry.as_str()) == Some(value) + }) +} + +#[test] +fn report_to_wire_parts_roundtrips_pubkey_event_and_file_reports() { + let profile = to_wire_parts(&profile_report()).unwrap(); + assert_eq!(profile.kind, KIND_REPORT); + assert!(profile.content.is_empty()); + assert!(has_tag(&profile.tags, TAG_P, REPORTED)); + assert_eq!( + profile.tags[0].get(2).map(|value| value.as_str()), + Some("spam") + ); + let decoded = report_from_event(profile.kind, &profile.tags, &profile.content).unwrap(); + assert_eq!(decoded.reported_pubkey, REPORTED); + assert_eq!(decoded.report_type, RadrootsReportType::Spam); + + let event = to_wire_parts(&event_report()).unwrap(); + assert_eq!(event.content, "Contains prohibited listing text"); + assert!(has_tag(&event.tags, TAG_E, EVENT_ID)); + let decoded = report_from_event(event.kind, &event.tags, &event.content).unwrap(); + assert!(matches!( + decoded.event, + Some(RadrootsSocialTarget::Event { .. }) + )); + assert_eq!(decoded.report_type, RadrootsReportType::Illegal); + + let file = to_wire_parts(&file_report()).unwrap(); + assert!(has_tag(&file.tags, TAG_SHA256, FILE_HASH)); + assert!(has_tag( + &file.tags, + TAG_SERVER, + "https://media.example.test/blob" + )); + assert!(has_tag( + &file.tags, + TAG_MAGNET, + "magnet:?xt=urn:btih:example" + )); + let decoded = report_from_event(file.kind, &file.tags, &file.content).unwrap(); + assert_eq!( + decoded.file.and_then(|target| target.sha256).as_deref(), + Some(FILE_HASH) + ); +} + +#[test] +fn report_codec_rejects_missing_pubkey_unknown_type_bad_hash_and_wrong_kind() { + let mut report = profile_report(); + report.reported_pubkey = " ".to_string(); + assert!(matches!( + report_build_tags(&report), + Err(EventEncodeError::EmptyRequiredField("reported_pubkey")) + )); + + assert!(matches!( + to_wire_parts_with_kind(&profile_report(), KIND_POST), + Err(EventEncodeError::InvalidKind(KIND_POST)) + )); + + let mut report = file_report(); + report.file.as_mut().unwrap().sha256 = Some("bad".to_string()); + assert!(matches!( + to_wire_parts(&report), + Err(EventEncodeError::InvalidField("file.sha256")) + )); + + let err = report_from_event(KIND_REPORT, &[], "").unwrap_err(); + assert!(matches!(err, EventParseError::MissingTag(TAG_P))); + + let tags = vec![vec![ + TAG_P.to_string(), + REPORTED.to_string(), + "unknown".to_string(), + ]]; + let err = report_from_event(KIND_REPORT, &tags, "").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag(TAG_P))); + + let tags = vec![ + vec![ + TAG_P.to_string(), + REPORTED.to_string(), + "malware".to_string(), + ], + vec![ + TAG_SHA256.to_string(), + "bad".to_string(), + "malware".to_string(), + ], + ]; + let err = report_from_event(KIND_REPORT, &tags, "").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag(TAG_SHA256))); + + let err = report_from_event(KIND_POST, &tags, "").unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "1984", + got: KIND_POST + } + )); +} + +#[test] +fn report_wrappers_preserve_event_metadata() { + let parts = to_wire_parts(&event_report()).unwrap(); + let data = data_from_event( + "report_id".to_string(), + "author".to_string(), + 12, + parts.kind, + parts.content.clone(), + parts.tags.clone(), + ) + .unwrap(); + assert_eq!(data.kind, KIND_REPORT); + assert_eq!(data.data.report_type, RadrootsReportType::Illegal); + + let parsed = parsed_from_event( + "report_id".to_string(), + "author".to_string(), + 12, + parts.kind, + parts.content, + parts.tags, + "sig".to_string(), + ) + .unwrap(); + assert_eq!(parsed.event.sig, "sig"); + assert_eq!(parsed.data.data.reported_pubkey, REPORTED); +} diff --git a/crates/events_codec/tests/repost.rs b/crates/events_codec/tests/repost.rs @@ -0,0 +1,202 @@ +#![cfg(feature = "serde_json")] + +use radroots_events::{ + kinds::{KIND_ARTICLE, KIND_GENERIC_REPOST, KIND_POST, KIND_REPOST}, + repost::{RadrootsGenericRepost, RadrootsRepost}, + social::RadrootsSocialTarget, + tags::{TAG_A, TAG_E, TAG_K, TAG_P}, +}; +use radroots_events_codec::{ + error::{EventEncodeError, EventParseError}, + repost::{ + decode::{ + generic_repost_data_from_event, generic_repost_from_event, + generic_repost_parsed_from_event, repost_data_from_event, repost_from_event, + repost_parsed_from_event, + }, + encode::{ + generic_repost_build_tags, generic_repost_to_wire_parts, + generic_repost_to_wire_parts_with_kind, repost_build_tags, repost_to_wire_parts, + repost_to_wire_parts_with_kind, + }, + }, +}; + +const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const ARTICLE_D_TAG: &str = "DDDDDDDDDDDDDDDDDDDDDA"; + +fn note_repost() -> RadrootsRepost { + RadrootsRepost { + target: RadrootsSocialTarget::Event { + id: EVENT_ID.to_string(), + author: Some(AUTHOR.to_string()), + event_kind: Some(KIND_POST), + relays: Some(vec!["wss://relay.example.test".to_string()]), + }, + content: None, + } +} + +fn generic_article_repost() -> RadrootsGenericRepost { + RadrootsGenericRepost { + target: RadrootsSocialTarget::Address { + address: format!("{KIND_ARTICLE}:{AUTHOR}:{ARTICLE_D_TAG}"), + author: Some(AUTHOR.to_string()), + event_kind: Some(KIND_ARTICLE), + relays: Some(vec!["wss://relay.example.test".to_string()]), + }, + target_kind: KIND_ARTICLE, + content: Some("{\"kind\":30023}".to_string()), + } +} + +fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool { + tags.iter().any(|tag| { + tag.first().map(|entry| entry.as_str()) == Some(key) + && tag.get(1).map(|entry| entry.as_str()) == Some(value) + }) +} + +#[test] +fn repost_to_wire_parts_roundtrips_kind_one_target() { + let repost = note_repost(); + let parts = repost_to_wire_parts(&repost).unwrap(); + + assert_eq!(parts.kind, KIND_REPOST); + assert!(parts.content.is_empty()); + assert!(has_tag(&parts.tags, TAG_E, EVENT_ID)); + assert!(has_tag(&parts.tags, TAG_P, AUTHOR)); + + let decoded = repost_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert!(matches!( + decoded.target, + RadrootsSocialTarget::Event { + event_kind: Some(KIND_POST), + .. + } + )); + assert!(decoded.content.is_none()); +} + +#[test] +fn generic_repost_to_wire_parts_roundtrips_address_target() { + let repost = generic_article_repost(); + let parts = generic_repost_to_wire_parts(&repost).unwrap(); + + assert_eq!(parts.kind, KIND_GENERIC_REPOST); + assert_eq!(parts.content, "{\"kind\":30023}"); + assert!(has_tag( + &parts.tags, + TAG_A, + format!("{KIND_ARTICLE}:{AUTHOR}:{ARTICLE_D_TAG}").as_str() + )); + assert!(has_tag( + &parts.tags, + TAG_K, + KIND_ARTICLE.to_string().as_str() + )); + + let decoded = generic_repost_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.target_kind, KIND_ARTICLE); + assert!(matches!( + decoded.target, + RadrootsSocialTarget::Address { + event_kind: Some(KIND_ARTICLE), + .. + } + )); + assert_eq!(decoded.content.as_deref(), Some("{\"kind\":30023}")); +} + +#[test] +fn repost_codecs_reject_wrong_kind_and_wrong_target_kind() { + assert!(matches!( + repost_to_wire_parts_with_kind(&note_repost(), KIND_GENERIC_REPOST), + Err(EventEncodeError::InvalidKind(KIND_GENERIC_REPOST)) + )); + assert!(matches!( + generic_repost_to_wire_parts_with_kind(&generic_article_repost(), KIND_REPOST), + Err(EventEncodeError::InvalidKind(KIND_REPOST)) + )); + + let mut repost = note_repost(); + if let RadrootsSocialTarget::Event { event_kind, .. } = &mut repost.target { + *event_kind = Some(KIND_ARTICLE); + } + assert!(matches!( + repost_build_tags(&repost), + Err(EventEncodeError::InvalidField("target_kind")) + )); + + let mut generic = generic_article_repost(); + generic.target_kind = KIND_POST; + assert!(matches!( + generic_repost_build_tags(&generic), + Err(EventEncodeError::InvalidField("target_kind")) + )); + + let err = repost_from_event(KIND_GENERIC_REPOST, &[], "").unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "6", + got: KIND_GENERIC_REPOST + } + )); + + let err = generic_repost_from_event(KIND_GENERIC_REPOST, &[], "").unwrap_err(); + assert!(matches!(err, EventParseError::MissingTag(TAG_K))); +} + +#[test] +fn repost_wrappers_preserve_event_metadata() { + let parts = repost_to_wire_parts(&note_repost()).unwrap(); + let data = repost_data_from_event( + "repost_id".to_string(), + "author".to_string(), + 10, + parts.kind, + parts.content.clone(), + parts.tags.clone(), + ) + .unwrap(); + assert_eq!(data.kind, KIND_REPOST); + assert_eq!(data.published_at, 10); + + let parsed = repost_parsed_from_event( + "repost_id".to_string(), + "author".to_string(), + 10, + parts.kind, + parts.content, + parts.tags, + "sig".to_string(), + ) + .unwrap(); + assert_eq!(parsed.event.sig, "sig"); + + let generic_parts = generic_repost_to_wire_parts(&generic_article_repost()).unwrap(); + let generic_data = generic_repost_data_from_event( + "generic_id".to_string(), + "author".to_string(), + 11, + generic_parts.kind, + generic_parts.content.clone(), + generic_parts.tags.clone(), + ) + .unwrap(); + assert_eq!(generic_data.data.target_kind, KIND_ARTICLE); + + let generic_parsed = generic_repost_parsed_from_event( + "generic_id".to_string(), + "author".to_string(), + 11, + generic_parts.kind, + generic_parts.content, + generic_parts.tags, + "sig".to_string(), + ) + .unwrap(); + assert_eq!(generic_parsed.event.created_at, 11); +}