commit a547226bd8966beab77325cc9d24abd0845d4651 parent 678c66042402ca3618ea80278e59c5aed35f55e0 Author: triesap <tyson@radroots.org> Date: Fri, 12 Jun 2026 16:26:43 -0700 events_codec: harden codec validation - reject wrong-kind output for post comment and reaction encoders - enforce NIP-22 kind one exclusion for comments - require farm file support for workspace media manifests - add file metadata and report edge-case contract coverage Diffstat:
17 files changed, 299 insertions(+), 38 deletions(-)
diff --git a/crates/events_codec/src/comment/decode.rs b/crates/events_codec/src/comment/decode.rs @@ -7,7 +7,7 @@ use alloc::{ use radroots_events::{ RadrootsNostrEvent, comment::RadrootsComment, - kinds::KIND_COMMENT, + kinds::{KIND_COMMENT, KIND_POST}, social::RadrootsSocialTarget, tags::{TAG_E_PREV, TAG_E_ROOT}, }; @@ -109,6 +109,7 @@ fn parse_comment_target( .ok_or(EventParseError::InvalidTag(keys.event))?; validate_lowercase_hex_64_tag(&id, keys.event)?; let kind = required_numeric_kind(tags, keys.kind)?; + validate_comment_target_kind(kind, keys.kind)?; let author = required_author(tags, keys.author)?; let relays = if tag.len() > 2 { Some(tag[2..].to_vec()) @@ -130,6 +131,7 @@ fn parse_comment_target( .ok_or(EventParseError::InvalidTag(keys.address))?; let address = parse_address_tag(&value, keys.address)?; let kind = required_numeric_kind(tags, keys.kind)?; + validate_comment_target_kind(kind, keys.kind)?; if kind != address.kind { return Err(EventParseError::InvalidTag(keys.kind)); } @@ -161,6 +163,9 @@ fn parse_comment_target( return Err(EventParseError::InvalidTag(keys.external)); } let external_kind = required_kind_value(tags, keys.kind)?; + if external_kind == "1" { + return Err(EventParseError::InvalidTag(keys.kind)); + } let hint = tag.get(2).filter(|value| !value.trim().is_empty()).cloned(); Ok(RadrootsSocialTarget::External { id, @@ -169,6 +174,14 @@ fn parse_comment_target( }) } +fn validate_comment_target_kind(kind: u32, key: &'static str) -> Result<(), EventParseError> { + if kind == KIND_POST { + Err(EventParseError::InvalidTag(key)) + } else { + Ok(()) + } +} + 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)) diff --git a/crates/events_codec/src/comment/encode.rs b/crates/events_codec/src/comment/encode.rs @@ -6,7 +6,9 @@ use alloc::{ }; use radroots_events::{ - comment::RadrootsComment, kinds::KIND_COMMENT, social::RadrootsSocialTarget, + comment::RadrootsComment, + kinds::{KIND_COMMENT, KIND_POST}, + social::RadrootsSocialTarget, }; use crate::error::EventEncodeError; @@ -32,6 +34,9 @@ pub fn to_wire_parts_with_kind( comment: &RadrootsComment, kind: u32, ) -> Result<WireEventParts, EventEncodeError> { + if kind != DEFAULT_KIND { + return Err(EventEncodeError::InvalidKind(kind)); + } if comment.content.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("content")); } @@ -94,6 +99,7 @@ fn push_comment_target( .ok_or(EventEncodeError::EmptyRequiredField(keys.field))?; validate_non_empty_field(author, keys.field)?; let kind = event_kind.ok_or(EventEncodeError::EmptyRequiredField(keys.field))?; + validate_comment_target_kind(kind, keys.field)?; let mut event_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); event_tag.push(keys.event.to_string()); event_tag.push(id.clone()); @@ -112,6 +118,7 @@ fn push_comment_target( } => { let parsed = parse_address_tag(address, keys.field) .map_err(|_| EventEncodeError::InvalidField(keys.field))?; + validate_comment_target_kind(parsed.kind, keys.field)?; if let Some(kind) = event_kind { if *kind != parsed.kind { return Err(EventEncodeError::InvalidField(keys.field)); @@ -142,6 +149,9 @@ fn push_comment_target( } => { validate_non_empty_field(id, keys.field)?; validate_non_empty_field(external_kind, keys.field)?; + if external_kind == "1" { + return Err(EventEncodeError::InvalidField(keys.field)); + } let mut external_tag = Vec::with_capacity(3); external_tag.push(keys.external.to_string()); external_tag.push(id.clone()); @@ -154,3 +164,11 @@ fn push_comment_target( } Ok(()) } + +fn validate_comment_target_kind(kind: u32, field: &'static str) -> Result<(), EventEncodeError> { + if kind == KIND_POST { + Err(EventEncodeError::InvalidField(field)) + } else { + Ok(()) + } +} diff --git a/crates/events_codec/src/farm_workspace/decode.rs b/crates/events_codec/src/farm_workspace/decode.rs @@ -12,7 +12,7 @@ use radroots_events::{ KIND_FARM_WORKSPACE_MANIFEST, RADROOTS_FARM_WORKSPACE_SCHEMA, RADROOTS_FARM_WORKSPACE_TAG, RadrootsFarmWorkspaceManifest, }, - kinds::{KIND_FARM, KIND_FARM_CRDT_CHANGE}, + kinds::{KIND_FARM, KIND_FARM_CRDT_CHANGE, KIND_FARM_FILE_METADATA}, tags::{TAG_A, TAG_D, TAG_H, TAG_P, TAG_T}, }; @@ -147,6 +147,11 @@ fn validate_manifest_content( { return Err(EventParseError::InvalidJson("supported_kinds")); } + if !manifest.media_servers.is_empty() + && !manifest.supported_kinds.contains(&KIND_FARM_FILE_METADATA) + { + return Err(EventParseError::InvalidJson("supported_kinds")); + } Ok(()) } diff --git a/crates/events_codec/src/farm_workspace/encode.rs b/crates/events_codec/src/farm_workspace/encode.rs @@ -9,7 +9,7 @@ use radroots_events::{ KIND_FARM_WORKSPACE_MANIFEST, RADROOTS_FARM_WORKSPACE_SCHEMA, RADROOTS_FARM_WORKSPACE_TAG, RadrootsFarmWorkspaceManifest, }, - kinds::{KIND_FARM, KIND_FARM_CRDT_CHANGE}, + kinds::{KIND_FARM, KIND_FARM_CRDT_CHANGE, KIND_FARM_FILE_METADATA}, tags::{TAG_A, TAG_D, TAG_H, TAG_P, TAG_T}, }; @@ -80,6 +80,11 @@ pub(crate) fn validate_manifest( { return Err(EventEncodeError::InvalidField("supported_kinds")); } + if !manifest.media_servers.is_empty() + && !manifest.supported_kinds.contains(&KIND_FARM_FILE_METADATA) + { + return Err(EventEncodeError::InvalidField("supported_kinds")); + } for relay in &manifest.relays { validate_non_empty_field(&relay.url, "relays.url")?; } diff --git a/crates/events_codec/src/farm_workspace/mod.rs b/crates/events_codec/src/farm_workspace/mod.rs @@ -14,7 +14,7 @@ mod tests { RadrootsFarmWorkspaceManifest, RadrootsFarmWorkspaceMediaServer, RadrootsFarmWorkspaceRelay, RadrootsFarmWorkspaceRelayMode, }, - kinds::KIND_POST, + kinds::{KIND_FARM_FILE_METADATA, KIND_POST}, }; use crate::error::{EventEncodeError, EventParseError}; @@ -48,7 +48,14 @@ mod tests { assert_eq!(decoded.d_tag, D_TAG); assert_eq!(decoded.schema, RADROOTS_FARM_WORKSPACE_SCHEMA); assert_eq!(decoded.farm_group_id, GROUP_ID); - assert_eq!(decoded.supported_kinds, vec![KIND_FARM_CRDT_CHANGE, 30078]); + assert_eq!( + decoded.supported_kinds, + vec![ + KIND_FARM_CRDT_CHANGE, + KIND_FARM_WORKSPACE_MANIFEST, + KIND_FARM_FILE_METADATA + ] + ); } #[test] @@ -120,6 +127,37 @@ mod tests { )); } + #[test] + fn farm_workspace_manifest_requires_farm_file_support_for_media_servers() { + let mut no_file_support = sample_manifest(); + no_file_support.supported_kinds = vec![KIND_FARM_CRDT_CHANGE, KIND_FARM_WORKSPACE_MANIFEST]; + let encode_err = to_wire_parts(&no_file_support).unwrap_err(); + assert!(matches!( + encode_err, + EventEncodeError::InvalidField("supported_kinds") + )); + + let mut no_media = no_file_support.clone(); + no_media.media_servers.clear(); + let parts = to_wire_parts(&no_media).expect("non-media manifest remains valid"); + let decoded = farm_workspace_from_event(parts.kind, &parts.tags, &parts.content) + .expect("non-media manifest decodes"); + assert!(decoded.media_servers.is_empty()); + + let mut content_missing_file_support = sample_manifest(); + content_missing_file_support.supported_kinds = + vec![KIND_FARM_CRDT_CHANGE, KIND_FARM_WORKSPACE_MANIFEST]; + let content = + serde_json::to_string(&content_missing_file_support).expect("workspace content"); + let tags = farm_workspace_build_tags(&sample_manifest()).expect("workspace tags"); + let parse_err = + farm_workspace_from_event(KIND_FARM_WORKSPACE_MANIFEST, &tags, &content).unwrap_err(); + assert!(matches!( + parse_err, + EventParseError::InvalidJson("supported_kinds") + )); + } + fn sample_manifest() -> RadrootsFarmWorkspaceManifest { RadrootsFarmWorkspaceManifest { d_tag: D_TAG.to_string(), @@ -139,7 +177,11 @@ mod tests { url: "https://media.example.invalid/farm/field-group".to_string(), service: "RadrootsPrivateMedia".to_string(), }], - supported_kinds: vec![KIND_FARM_CRDT_CHANGE, KIND_FARM_WORKSPACE_MANIFEST], + supported_kinds: vec![ + KIND_FARM_CRDT_CHANGE, + KIND_FARM_WORKSPACE_MANIFEST, + KIND_FARM_FILE_METADATA, + ], protocol_version: RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION.to_string(), created_at_ms: 1_780_000_000_000, updated_at_ms: None, diff --git a/crates/events_codec/src/post/encode.rs b/crates/events_codec/src/post/encode.rs @@ -61,6 +61,9 @@ pub fn to_wire_parts_with_kind( post: &RadrootsPost, kind: u32, ) -> Result<WireEventParts, EventEncodeError> { + if kind != DEFAULT_KIND { + return Err(EventEncodeError::InvalidKind(kind)); + } if post.content.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("content")); } diff --git a/crates/events_codec/src/reaction/encode.rs b/crates/events_codec/src/reaction/encode.rs @@ -33,6 +33,9 @@ pub fn to_wire_parts_with_kind( reaction: &RadrootsReaction, kind: u32, ) -> Result<WireEventParts, EventEncodeError> { + if kind != DEFAULT_KIND { + return Err(EventEncodeError::InvalidKind(kind)); + } let tags = reaction_build_tags(reaction)?; Ok(WireEventParts { kind, diff --git a/crates/events_codec/src/report/decode.rs b/crates/events_codec/src/report/decode.rs @@ -27,6 +27,7 @@ pub fn report_from_event( } let p_tag = find_tag(tags, TAG_P).ok_or(EventParseError::MissingTag(TAG_P))?; let reported_pubkey = required_tag_value(tags, TAG_P)?; + validate_lowercase_hex_64_tag(&reported_pubkey, TAG_P)?; let report_type = parse_report_type( p_tag .get(2) diff --git a/crates/events_codec/src/report/encode.rs b/crates/events_codec/src/report/encode.rs @@ -53,6 +53,7 @@ pub fn to_wire_parts_with_kind( fn validate_report(report: &RadrootsReport) -> Result<(), EventEncodeError> { validate_non_empty_field(&report.reported_pubkey, "reported_pubkey")?; + validate_lowercase_hex_64(&report.reported_pubkey, "reported_pubkey")?; if let Some(file) = report.file.as_ref() { validate_file_target(file)?; } diff --git a/crates/events_codec/tests/comment.rs b/crates/events_codec/tests/comment.rs @@ -148,22 +148,59 @@ fn comment_roundtrips_event_and_address_targets() { assert_address_target(&parsed.parent, PARENT_AUTHOR, KIND_ARTICLE, D_TAG); assert_eq!(parsed.content, "hello"); - let custom_parts = to_wire_parts_with_kind(&comment, KIND_POST).unwrap(); - assert_eq!(custom_parts.kind, KIND_POST); + assert!(matches!( + to_wire_parts_with_kind(&comment, KIND_POST), + Err(EventEncodeError::InvalidKind(KIND_POST)) + )); } #[test] -fn comment_roundtrips_short_text_note_targets() { - let comment = RadrootsComment { +fn comment_rejects_short_text_note_targets() { + let root_kind_one = RadrootsComment { root: event_target(ROOT_ID, AUTHOR, KIND_POST), + parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE), + content: "note reply".to_string(), + }; + assert!(matches!( + comment_build_tags(&root_kind_one), + Err(EventEncodeError::InvalidField("root")) + )); + + let parent_kind_one = RadrootsComment { + root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE), parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_POST), content: "note reply".to_string(), }; - let parts = to_wire_parts(&comment).unwrap(); - let parsed = comment_from_tags(parts.kind, &parts.tags, &parts.content).unwrap(); + assert!(matches!( + comment_build_tags(&parent_kind_one), + Err(EventEncodeError::InvalidField("parent")) + )); + + let root_kind_one_tags = vec![ + vec!["E".to_string(), ROOT_ID.to_string()], + vec!["P".to_string(), AUTHOR.to_string()], + vec!["K".to_string(), KIND_POST.to_string()], + vec!["e".to_string(), PARENT_ID.to_string()], + vec!["p".to_string(), PARENT_AUTHOR.to_string()], + vec!["k".to_string(), KIND_ARTICLE.to_string()], + ]; + assert!(matches!( + comment_from_tags(KIND_COMMENT, &root_kind_one_tags, "note reply"), + Err(EventParseError::InvalidTag("K")) + )); - assert_event_target(&parsed.root, ROOT_ID, AUTHOR, KIND_POST); - assert_event_target(&parsed.parent, PARENT_ID, PARENT_AUTHOR, KIND_POST); + let parent_kind_one_tags = vec![ + vec!["E".to_string(), ROOT_ID.to_string()], + vec!["P".to_string(), AUTHOR.to_string()], + vec!["K".to_string(), KIND_ARTICLE.to_string()], + vec!["e".to_string(), PARENT_ID.to_string()], + vec!["p".to_string(), PARENT_AUTHOR.to_string()], + vec!["k".to_string(), KIND_POST.to_string()], + ]; + assert!(matches!( + comment_from_tags(KIND_COMMENT, &parent_kind_one_tags, "note reply"), + Err(EventParseError::InvalidTag("k")) + )); } #[test] diff --git a/crates/events_codec/tests/field_events.rs b/crates/events_codec/tests/field_events.rs @@ -18,7 +18,7 @@ use radroots_events::{ RadrootsGroupPutUser, RadrootsGroupUserRef, }, http_auth::RadrootsHttpAuth, - kinds::KIND_POST, + kinds::{KIND_FARM_FILE_METADATA, KIND_POST}, relay_auth::RadrootsRelayAuth, }; use radroots_events_codec::{ @@ -309,7 +309,7 @@ fn sample_workspace() -> RadrootsFarmWorkspaceManifest { url: "https://media.example.invalid/farm/field-group".to_string(), service: "RadrootsPrivateMedia".to_string(), }], - supported_kinds: vec![78, 30078], + supported_kinds: vec![78, 30078, KIND_FARM_FILE_METADATA], protocol_version: RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION.to_string(), created_at_ms: 1_780_000_000_000, updated_at_ms: None, @@ -377,7 +377,7 @@ fn sample_group_metadata() -> RadrootsGroupEditableMetadata { is_restricted: true, is_closed: false, is_hidden: false, - supported_kinds: Some(vec![78, 30078]), + supported_kinds: Some(vec![78, 30078, KIND_FARM_FILE_METADATA]), } } diff --git a/crates/events_codec/tests/file_metadata.rs b/crates/events_codec/tests/file_metadata.rs @@ -1,6 +1,9 @@ #![cfg(feature = "serde_json")] use radroots_events::{ + farm_crdt::RadrootsFarmCrdtDocumentKind, + farm_file::{RadrootsFarmFileDimensions, RadrootsFarmFileMetadata}, + farm_workspace::RadrootsFarmWorkspaceRef, file_metadata::RadrootsFileMetadata, kinds::{KIND_POST, KIND_PUBLIC_FILE_METADATA}, social::{RadrootsSocialMediaDimensions, RadrootsSocialMediaThumbnail}, @@ -11,6 +14,9 @@ use radroots_events::{ }; use radroots_events_codec::{ error::{EventEncodeError, EventParseError}, + farm_file::{ + decode::farm_file_metadata_from_event, encode::to_wire_parts as farm_file_to_wire_parts, + }, file_metadata::{ decode::{data_from_event, file_metadata_from_event, parsed_from_event}, encode::{file_metadata_build_tags, to_wire_parts, to_wire_parts_with_kind}, @@ -49,6 +55,31 @@ fn sample_metadata() -> RadrootsFileMetadata { } } +fn sample_farm_file_metadata() -> RadrootsFarmFileMetadata { + RadrootsFarmFileMetadata { + d_tag: "BBBBBBBBBBBBBBBBBBBBBA".to_string(), + workspace: RadrootsFarmWorkspaceRef { + pubkey: "workspace_pubkey".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + farm_group_id: "field-group".to_string(), + owner_document_id: "CCCCCCCCCCCCCCCCCCCCCA".to_string(), + owner_document_kind: RadrootsFarmCrdtDocumentKind::FarmTask, + caption: Some("Private crop photo".to_string()), + url: "https://media.example.test/private.jpg".to_string(), + mime_type: "image/jpeg".to_string(), + sha256: VALID_HASH.to_string(), + original_sha256: None, + size_bytes: Some(2048), + dimensions: Some(RadrootsFarmFileDimensions { w: 800, h: 600 }), + blurhash: None, + thumb: None, + image: None, + alt: Some("Private rows".to_string()), + fallbacks: Vec::new(), + } +} + fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool { tags.iter().any(|tag| { tag.first().map(|entry| entry.as_str()) == Some(key) @@ -123,6 +154,27 @@ fn file_metadata_to_wire_parts_roundtrips_nip94_tags() { } #[test] +fn file_metadata_public_and_private_kind1063_contracts_do_not_cross_decode() { + let public = to_wire_parts(&sample_metadata()).unwrap(); + let decoded_public = + file_metadata_from_event(public.kind, &public.tags, &public.content).unwrap(); + assert_eq!(decoded_public.url, "https://media.example.test/field.jpg"); + assert!(matches!( + farm_file_metadata_from_event(public.kind, &public.tags, &public.content), + Err(EventParseError::MissingTag("d")) + )); + + let private = farm_file_to_wire_parts(&sample_farm_file_metadata()).unwrap(); + let decoded_private = + farm_file_metadata_from_event(private.kind, &private.tags, &private.content).unwrap(); + assert_eq!(decoded_private.owner_document_id, "CCCCCCCCCCCCCCCCCCCCCA"); + assert!(matches!( + file_metadata_from_event(private.kind, &private.tags, &private.content), + Err(EventParseError::InvalidTag("radroots:owner_document")) + )); +} + +#[test] fn file_metadata_codec_requires_kind_required_tags_and_hash_shape() { let mut metadata = sample_metadata(); metadata.url = "ipfs://field.jpg".to_string(); @@ -151,6 +203,20 @@ fn file_metadata_codec_requires_kind_required_tags_and_hash_shape() { )); let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap(); + tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_MIME)); + assert!(matches!( + file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""), + Err(EventParseError::MissingTag(TAG_MIME)) + )); + + let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap(); + tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_SHA256)); + assert!(matches!( + file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""), + Err(EventParseError::MissingTag(TAG_SHA256)) + )); + + let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap(); let hash_tag = tags .iter_mut() .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SHA256)) diff --git a/crates/events_codec/tests/post.rs b/crates/events_codec/tests/post.rs @@ -1,6 +1,6 @@ use radroots_events::{ farm::RadrootsFarmRef, - kinds::{KIND_COMMENT, KIND_POST}, + kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_POST}, post::RadrootsPost, social::{ RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaMetadata, @@ -12,7 +12,9 @@ use radroots_events_codec::error::{EventEncodeError, EventParseError}; use radroots_events_codec::post::decode::{ data_from_event, parsed_from_event, post_from_content, post_from_event, }; -use radroots_events_codec::post::encode::{post_build_tags, to_wire_parts}; +use radroots_events_codec::post::encode::{ + post_build_tags, to_wire_parts, to_wire_parts_with_kind, +}; const QUOTE_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; @@ -56,6 +58,24 @@ fn post_to_wire_parts_sets_kind_and_content() { } #[test] +fn post_to_wire_parts_with_kind_rejects_non_post_kind() { + let post = RadrootsPost { + content: "hello".to_string(), + farm: None, + address_refs: None, + location: None, + topics: None, + quote_refs: None, + media: None, + }; + + assert!(matches!( + to_wire_parts_with_kind(&post, KIND_ARTICLE), + Err(EventEncodeError::InvalidKind(KIND_ARTICLE)) + )); +} + +#[test] fn post_to_wire_parts_roundtrips_optional_social_tags() { let post = RadrootsPost { content: "field update".to_string(), diff --git a/crates/events_codec/tests/reaction.rs b/crates/events_codec/tests/reaction.rs @@ -1,5 +1,5 @@ use radroots_events::{ - kinds::{KIND_ARTICLE, KIND_POST, KIND_REACTION}, + kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_POST, KIND_REACTION}, reaction::RadrootsReaction, social::RadrootsSocialTarget, tags::TAG_E_ROOT, @@ -113,14 +113,15 @@ fn reaction_to_wire_parts_accepts_empty_plus_minus_emoji_and_custom_content() { } #[test] -fn reaction_to_wire_parts_with_kind_keeps_requested_kind() { +fn reaction_to_wire_parts_with_kind_rejects_non_reaction_kind() { let reaction = RadrootsReaction { target: event_target(), content: "+".to_string(), }; - let parts = to_wire_parts_with_kind(&reaction, KIND_POST).unwrap(); - assert_eq!(parts.kind, KIND_POST); - assert_eq!(parts.content, "+"); + assert!(matches!( + to_wire_parts_with_kind(&reaction, KIND_COMMENT), + Err(EventEncodeError::InvalidKind(KIND_COMMENT)) + )); } #[test] diff --git a/crates/events_codec/tests/report.rs b/crates/events_codec/tests/report.rs @@ -4,7 +4,7 @@ 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}, + tags::{TAG_A, TAG_E, TAG_MAGNET, TAG_P, TAG_SERVER, TAG_SHA256}, }; use radroots_events_codec::{ error::{EventEncodeError, EventParseError}, @@ -116,6 +116,13 @@ fn report_codec_rejects_missing_pubkey_unknown_type_bad_hash_and_wrong_kind() { Err(EventEncodeError::EmptyRequiredField("reported_pubkey")) )); + let mut report = profile_report(); + report.reported_pubkey = "not-a-pubkey".to_string(); + assert!(matches!( + report_build_tags(&report), + Err(EventEncodeError::InvalidField("reported_pubkey")) + )); + assert!(matches!( to_wire_parts_with_kind(&profile_report(), KIND_POST), Err(EventEncodeError::InvalidKind(KIND_POST)) @@ -133,6 +140,14 @@ fn report_codec_rejects_missing_pubkey_unknown_type_bad_hash_and_wrong_kind() { let tags = vec![vec![ TAG_P.to_string(), + "not-a-pubkey".to_string(), + "spam".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(), "unknown".to_string(), ]]; @@ -165,6 +180,42 @@ fn report_codec_rejects_missing_pubkey_unknown_type_bad_hash_and_wrong_kind() { } #[test] +fn report_codec_rejects_bad_event_targets_and_report_type_mismatches() { + let mut report = event_report(); + report.event = Some(RadrootsSocialTarget::External { + id: "https://example.test/report".to_string(), + external_kind: "web".to_string(), + hint: None, + }); + assert!(matches!( + report_build_tags(&report), + Err(EventEncodeError::InvalidField("event")) + )); + + let tags = vec![ + vec![TAG_P.to_string(), REPORTED.to_string(), "spam".to_string()], + vec![ + TAG_E.to_string(), + EVENT_ID.to_string(), + "illegal".to_string(), + ], + ]; + let err = report_from_event(KIND_REPORT, &tags, "").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag(TAG_E))); + + let tags = vec![ + vec![TAG_P.to_string(), REPORTED.to_string(), "spam".to_string()], + vec![ + TAG_A.to_string(), + "bad-address".to_string(), + "spam".to_string(), + ], + ]; + let err = report_from_event(KIND_REPORT, &tags, "").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidNumber(TAG_A, _))); +} + +#[test] fn report_wrappers_preserve_event_metadata() { let parts = to_wire_parts(&event_report()).unwrap(); let data = data_from_event( diff --git a/spec/conformance/vectors/social/mvp.v1.json b/spec/conformance/vectors/social/mvp.v1.json @@ -144,8 +144,8 @@ } }, { - "id": "social_comment_kind_one_target_valid_006", - "kind": "social.comment.build_tags.valid", + "id": "social_comment_kind_one_target_invalid_006", + "kind": "social.comment.build_tags.invalid", "input": { "comment": { "root": { @@ -166,15 +166,9 @@ } }, "expected": { - "result": "ok", - "required_tags": [ - "E", - "P", - "K", - "e", - "p", - "k" - ] + "result": "error", + "error_class": "encode_error", + "field": "root" } }, { diff --git a/spec/social-events.md b/spec/social-events.md @@ -63,7 +63,8 @@ valid. `RadrootsComment` uses strict NIP-22 semantics. The target and scope model must support event-id, address, and external roots or parents through `E`/`e`, `A`/`a`, and `I`/`i` tags with matching -`K`/`k` kind metadata, including ordinary kind `1` short text note targets. Canonical decode must +`K`/`k` kind metadata. Canonical encode and decode must reject ordinary kind `1` short text note +targets; kind `1` replies belong to NIP-10 text-note reply semantics instead. Canonical decode must reject legacy `e_root` and `e_prev` fallback tags. `RadrootsReaction` uses strict NIP-25 semantics. Empty content, `+`, `-`, emoji, and custom reaction