commit 02436cb72a83bf2e430b634f90fdd009779f19cb parent bc143375b852dbd1e743c84e59338a062e3fffca Author: triesap <tyson@radroots.org> Date: Mon, 5 Jan 2026 19:31:57 +0000 events-codec: validate d_tag as base64url across codecs - Add shared d_tag base64url validator helpers - Enforce d_tag validation in encode paths with InvalidField - Validate parsed d-tag values and referenced d_tag tags on decode - Extend trade listing codec and tests to reject invalid d_tag Diffstat:
23 files changed, 177 insertions(+), 2 deletions(-)
diff --git a/events-codec/src/coop/decode.rs b/events-codec/src/coop/decode.rs @@ -11,6 +11,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; const DEFAULT_KIND: u32 = KIND_COOP; @@ -27,6 +28,7 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { if value.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_D)); } + validate_d_tag_tag(&value, TAG_D)?; Ok(value) } diff --git a/events-codec/src/coop/encode.rs b/events-codec/src/coop/encode.rs @@ -9,6 +9,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; #[cfg(feature = "serde_json")] @@ -28,6 +29,7 @@ pub fn coop_build_tags(coop: &RadrootsCoop) -> Result<Vec<Vec<String>>, EventEnc if coop.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("d_tag")); } + validate_d_tag(&coop.d_tag, "d_tag")?; if coop.name.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("name")); } @@ -55,6 +57,7 @@ pub fn coop_ref_tags(coop: &RadrootsCoopRef) -> Result<Vec<Vec<String>>, EventEn if coop.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("coop.d_tag")); } + validate_d_tag(&coop.d_tag, "coop.d_tag")?; let mut addr = String::new(); addr.push_str(&KIND_COOP.to_string()); addr.push(':'); diff --git a/events-codec/src/coop/list_sets.rs b/events-codec/src/coop/list_sets.rs @@ -8,6 +8,7 @@ use radroots_events::kinds::KIND_FARM; use radroots_events::list::RadrootsListEntry; use radroots_events::list_set::RadrootsListSet; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; const MEMBER_OF_COOPS: &str = "member_of.coops"; @@ -17,6 +18,7 @@ fn coop_list_set_id(coop_id: &str, suffix: &str) -> Result<String, EventEncodeEr if coop_id.is_empty() { return Err(EventEncodeError::EmptyRequiredField("coop_id")); } + validate_d_tag(coop_id, "coop_id")?; if suffix.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("list_set_suffix")); } @@ -49,6 +51,7 @@ fn farm_address(farm: &RadrootsFarmRef) -> Result<String, EventEncodeError> { if farm.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); } + validate_d_tag(&farm.d_tag, "farm.d_tag")?; let mut addr = String::new(); addr.push_str(&KIND_FARM.to_string()); addr.push(':'); diff --git a/events-codec/src/d_tag.rs b/events-codec/src/d_tag.rs @@ -0,0 +1,31 @@ +#![forbid(unsafe_code)] + +use crate::error::{EventEncodeError, EventParseError}; + +pub fn is_d_tag_base64url(value: &str) -> bool { + if value.is_empty() { + return false; + } + value.as_bytes().iter().all(|byte| { + matches!( + byte, + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' + ) + }) +} + +pub(crate) fn validate_d_tag(value: &str, field: &'static str) -> Result<(), EventEncodeError> { + if is_d_tag_base64url(value) { + Ok(()) + } else { + Err(EventEncodeError::InvalidField(field)) + } +} + +pub(crate) fn validate_d_tag_tag(value: &str, tag: &'static str) -> Result<(), EventParseError> { + if is_d_tag_base64url(value) { + Ok(()) + } else { + Err(EventParseError::InvalidTag(tag)) + } +} diff --git a/events-codec/src/document/decode.rs b/events-codec/src/document/decode.rs @@ -11,6 +11,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; const TAG_A: &str = "a"; @@ -29,6 +30,7 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { if value.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_D)); } + validate_d_tag_tag(&value, TAG_D)?; Ok(value) } diff --git a/events-codec/src/document/encode.rs b/events-codec/src/document/encode.rs @@ -8,6 +8,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; #[cfg(feature = "serde_json")] @@ -31,6 +32,7 @@ pub fn document_build_tags(document: &RadrootsDocument) -> Result<Vec<Vec<String if document.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("d_tag")); } + validate_d_tag(&document.d_tag, "d_tag")?; if document.doc_type.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("doc_type")); } diff --git a/events-codec/src/error.rs b/events-codec/src/error.rs @@ -37,6 +37,7 @@ impl std::error::Error for EventParseError { pub enum EventEncodeError { InvalidKind(u32), EmptyRequiredField(&'static str), + InvalidField(&'static str), Json, } @@ -47,6 +48,7 @@ impl fmt::Display for EventEncodeError { EventEncodeError::EmptyRequiredField(field) => { write!(f, "empty required field: {}", field) } + EventEncodeError::InvalidField(field) => write!(f, "invalid field: {}", field), EventEncodeError::Json => write!(f, "failed to serialize JSON"), } } diff --git a/events-codec/src/farm/decode.rs b/events-codec/src/farm/decode.rs @@ -10,6 +10,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; const DEFAULT_KIND: u32 = KIND_FARM; @@ -26,6 +27,7 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { if value.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_D)); } + validate_d_tag_tag(&value, TAG_D)?; Ok(value) } diff --git a/events-codec/src/farm/encode.rs b/events-codec/src/farm/encode.rs @@ -7,6 +7,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; #[cfg(feature = "serde_json")] @@ -26,6 +27,7 @@ pub fn farm_build_tags(farm: &RadrootsFarm) -> Result<Vec<Vec<String>>, EventEnc if farm.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("d_tag")); } + validate_d_tag(&farm.d_tag, "d_tag")?; if farm.name.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("name")); } @@ -53,6 +55,7 @@ pub fn farm_ref_tags(farm: &RadrootsFarmRef) -> Result<Vec<Vec<String>>, EventEn if farm.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); } + validate_d_tag(&farm.d_tag, "farm.d_tag")?; let mut addr = String::new(); addr.push_str(&KIND_FARM.to_string()); addr.push(':'); diff --git a/events-codec/src/farm/list_sets.rs b/events-codec/src/farm/list_sets.rs @@ -9,6 +9,7 @@ use radroots_events::plot::RadrootsPlot; use radroots_events::listing::RadrootsListing; use radroots_events::kinds::KIND_LISTING; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; use crate::plot::encode::plot_address; @@ -19,6 +20,7 @@ fn farm_list_set_id(farm_id: &str, suffix: &str) -> Result<String, EventEncodeEr if farm_id.is_empty() { return Err(EventEncodeError::EmptyRequiredField("farm_id")); } + validate_d_tag(farm_id, "farm_id")?; if suffix.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("list_set_suffix")); } @@ -141,6 +143,7 @@ where if listing_id.is_empty() { return Err(EventEncodeError::EmptyRequiredField("listing_id")); } + validate_d_tag(listing_id, "listing_id")?; let mut address = String::new(); address.push_str(&KIND_LISTING.to_string()); address.push(':'); diff --git a/events-codec/src/farm/mod.rs b/events-codec/src/farm/mod.rs @@ -13,6 +13,7 @@ mod tests { RadrootsGeoJsonPolygon, }; use radroots_events::plot::RadrootsPlot; + use crate::error::EventEncodeError; use crate::farm::encode::{farm_build_tags, farm_ref_tags}; use crate::farm::list_sets::{ farm_members_list_set, @@ -85,6 +86,23 @@ mod tests { } #[test] + fn farm_build_tags_rejects_invalid_d_tag() { + let farm = RadrootsFarm { + d_tag: "farm:invalid".to_string(), + name: "Test Farm".to_string(), + about: None, + website: None, + picture: None, + banner: None, + location: None, + tags: None, + }; + + let err = farm_build_tags(&farm).expect_err("expected invalid d_tag"); + assert!(matches!(err, EventEncodeError::InvalidField("d_tag"))); + } + + #[test] fn farm_ref_tags_include_p_and_a() { let farm = RadrootsFarmRef { pubkey: "farm_pubkey".to_string(), diff --git a/events-codec/src/lib.rs b/events-codec/src/lib.rs @@ -4,6 +4,7 @@ extern crate alloc; pub mod error; +pub mod d_tag; pub mod event_ref; pub mod job; pub mod profile; diff --git a/events-codec/src/listing/decode.rs b/events-codec/src/listing/decode.rs @@ -13,6 +13,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; const DEFAULT_KIND: u32 = KIND_LISTING; @@ -33,6 +34,7 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { if value.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_D)); } + validate_d_tag_tag(&value, TAG_D)?; Ok(value) } @@ -61,6 +63,7 @@ fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsListingFarmRef, EventP if pubkey.trim().is_empty() || d_tag.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_A)); } + validate_d_tag_tag(&d_tag, TAG_A)?; return Ok(RadrootsListingFarmRef { pubkey, d_tag }); } Err(EventParseError::MissingTag(TAG_A)) @@ -111,6 +114,7 @@ fn parse_resource_area(tags: &[Vec<String>]) -> Result<Option<RadrootsResourceAr if pubkey.trim().is_empty() || d_tag.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA)); } + validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA)?; Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag })) } @@ -144,6 +148,7 @@ fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, Event if pubkey.trim().is_empty() || d_tag.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT)); } + validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT)?; Ok(Some(RadrootsPlotRef { pubkey, d_tag })) } diff --git a/events-codec/src/listing/tags.rs b/events-codec/src/listing/tags.rs @@ -16,6 +16,7 @@ use radroots_events::resource_area::RadrootsResourceAreaRef; use radroots_events::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; use radroots_events::tags::TAG_D; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; const TAG_PRICE: &str = "price"; @@ -94,10 +95,11 @@ pub fn listing_tags_with_options( listing: &RadrootsListing, options: ListingTagOptions, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - let d_tag = listing.d_tag.trim(); - if d_tag.is_empty() { + let d_tag = listing.d_tag.as_str(); + if d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("d")); } + validate_d_tag(d_tag, "d")?; if listing.primary_bin_id.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("primary_bin_id")); } @@ -243,6 +245,7 @@ fn push_farm_tags( if farm.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); } + validate_d_tag(&farm.d_tag, "farm.d_tag")?; let mut address = String::new(); address.push_str(&KIND_FARM.to_string()); address.push(':'); @@ -263,6 +266,7 @@ fn tag_listing_resource_area( if area.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag")); } + validate_d_tag(&area.d_tag, "resource_area.d_tag")?; let mut address = String::new(); address.push_str(&KIND_RESOURCE_AREA.to_string()); address.push(':'); @@ -279,6 +283,7 @@ fn tag_listing_plot(plot: &RadrootsPlotRef) -> Result<Vec<String>, EventEncodeEr if plot.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("plot.d_tag")); } + validate_d_tag(&plot.d_tag, "plot.d_tag")?; let mut address = String::new(); address.push_str(&KIND_PLOT.to_string()); address.push(':'); diff --git a/events-codec/src/plot/decode.rs b/events-codec/src/plot/decode.rs @@ -11,6 +11,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; const TAG_A: &str = "a"; @@ -29,6 +30,7 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { if value.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_D)); } + validate_d_tag_tag(&value, TAG_D)?; Ok(value) } @@ -60,6 +62,7 @@ fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, EventParseErr if pubkey.trim().is_empty() || d_tag.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_A)); } + validate_d_tag_tag(&d_tag, TAG_A)?; Ok(RadrootsFarmRef { pubkey, d_tag }) } diff --git a/events-codec/src/plot/encode.rs b/events-codec/src/plot/encode.rs @@ -8,6 +8,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; #[cfg(feature = "serde_json")] @@ -44,6 +45,7 @@ pub fn plot_address(author_pubkey: &str, plot_id: &str) -> Result<String, EventE if plot_id.is_empty() { return Err(EventEncodeError::EmptyRequiredField("plot.d_tag")); } + validate_d_tag(plot_id, "plot.d_tag")?; let mut value = String::new(); value.push_str(&KIND_PLOT.to_string()); value.push(':'); @@ -57,6 +59,7 @@ pub fn plot_build_tags(plot: &RadrootsPlot) -> Result<Vec<Vec<String>>, EventEnc if plot.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("d_tag")); } + validate_d_tag(&plot.d_tag, "d_tag")?; if plot.name.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("name")); } @@ -66,6 +69,7 @@ pub fn plot_build_tags(plot: &RadrootsPlot) -> Result<Vec<Vec<String>>, EventEnc if plot.farm.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); } + validate_d_tag(&plot.farm.d_tag, "farm.d_tag")?; let mut tags = Vec::new(); push_tag(&mut tags, TAG_D, &plot.d_tag); push_tag(&mut tags, TAG_A, &farm_address(&plot.farm)); diff --git a/events-codec/src/resource_area/decode.rs b/events-codec/src/resource_area/decode.rs @@ -10,6 +10,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; const DEFAULT_KIND: u32 = KIND_RESOURCE_AREA; @@ -26,6 +27,7 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { if value.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_D)); } + validate_d_tag_tag(&value, TAG_D)?; Ok(value) } diff --git a/events-codec/src/resource_area/encode.rs b/events-codec/src/resource_area/encode.rs @@ -7,6 +7,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; #[cfg(feature = "serde_json")] @@ -31,6 +32,7 @@ fn resource_area_address(area: &RadrootsResourceAreaRef) -> Result<String, Event if area.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag")); } + validate_d_tag(&area.d_tag, "resource_area.d_tag")?; let mut addr = String::new(); addr.push_str(&KIND_RESOURCE_AREA.to_string()); addr.push(':'); @@ -46,6 +48,7 @@ pub fn resource_area_build_tags( if area.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("d_tag")); } + validate_d_tag(&area.d_tag, "d_tag")?; if area.name.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("name")); } diff --git a/events-codec/src/resource_area/list_sets.rs b/events-codec/src/resource_area/list_sets.rs @@ -9,6 +9,7 @@ use radroots_events::list::RadrootsListEntry; use radroots_events::list_set::RadrootsListSet; use radroots_events::plot::RadrootsPlotRef; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; fn resource_list_set_id(area_id: &str, suffix: &str) -> Result<String, EventEncodeError> { @@ -16,6 +17,7 @@ fn resource_list_set_id(area_id: &str, suffix: &str) -> Result<String, EventEnco if area_id.is_empty() { return Err(EventEncodeError::EmptyRequiredField("area_id")); } + validate_d_tag(area_id, "area_id")?; if suffix.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("list_set_suffix")); } @@ -48,6 +50,7 @@ fn farm_address(farm: &RadrootsFarmRef) -> Result<String, EventEncodeError> { if farm.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); } + validate_d_tag(&farm.d_tag, "farm.d_tag")?; let mut addr = String::new(); addr.push_str(&KIND_FARM.to_string()); addr.push(':'); @@ -64,6 +67,7 @@ fn plot_address(plot: &RadrootsPlotRef) -> Result<String, EventEncodeError> { if plot.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("plot.d_tag")); } + validate_d_tag(&plot.d_tag, "plot.d_tag")?; let mut addr = String::new(); addr.push_str(&KIND_PLOT.to_string()); addr.push(':'); diff --git a/events-codec/src/resource_cap/decode.rs b/events-codec/src/resource_cap/decode.rs @@ -10,6 +10,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; const DEFAULT_KIND: u32 = KIND_RESOURCE_HARVEST_CAP; @@ -26,6 +27,7 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { if value.trim().is_empty() { return Err(EventParseError::InvalidTag(TAG_D)); } + validate_d_tag_tag(&value, TAG_D)?; Ok(value) } diff --git a/events-codec/src/resource_cap/encode.rs b/events-codec/src/resource_cap/encode.rs @@ -7,6 +7,7 @@ use radroots_events::{ tags::TAG_D, }; +use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; #[cfg(feature = "serde_json")] @@ -37,6 +38,7 @@ fn resource_area_address(cap: &RadrootsResourceHarvestCap) -> Result<String, Eve if area.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("resource_area.d_tag")); } + validate_d_tag(&area.d_tag, "resource_area.d_tag")?; let mut addr = String::new(); addr.push_str(&KIND_RESOURCE_AREA.to_string()); addr.push(':'); @@ -52,6 +54,7 @@ pub fn resource_harvest_cap_build_tags( if cap.d_tag.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("d_tag")); } + validate_d_tag(&cap.d_tag, "d_tag")?; if cap.product.key.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("product.key")); } diff --git a/events-codec/tests/listing.rs b/events-codec/tests/listing.rs @@ -150,6 +150,13 @@ fn listing_build_tags_requires_d_tag() { } #[test] +fn listing_build_tags_rejects_invalid_d_tag() { + let listing = sample_listing("invalid:tag"); + let err = listing_build_tags(&listing).unwrap_err(); + assert!(matches!(err, EventEncodeError::InvalidField("d"))); +} + +#[test] fn listing_roundtrip_from_event() { let listing = sample_listing("listing-1"); let parts = to_wire_parts(&listing).unwrap(); diff --git a/trade/src/listing/codec.rs b/trade/src/listing/codec.rs @@ -17,6 +17,7 @@ use radroots_events::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; use radroots_events::plot::RadrootsPlotRef; use radroots_events::resource_area::RadrootsResourceAreaRef; use radroots_events::tags::TAG_D; +use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::error::EventEncodeError; use radroots_events_codec::listing::tags::{listing_tags_with_options, ListingTagOptions}; #[cfg(feature = "ts-rs")] @@ -101,6 +102,9 @@ fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, TradeListingParseError> { if value.trim().is_empty() { return Err(TradeListingParseError::InvalidTag(TAG_D.to_string())); } + if !is_d_tag_base64url(&value) { + return Err(TradeListingParseError::InvalidTag(TAG_D.to_string())); + } Ok(value) } @@ -175,6 +179,9 @@ fn map_listing_tags_error(err: EventEncodeError) -> TradeListingParseError { EventEncodeError::EmptyRequiredField(field) => { TradeListingParseError::MissingTag(field.to_string()) } + EventEncodeError::InvalidField(field) => { + TradeListingParseError::InvalidTag(field.to_string()) + } EventEncodeError::Json => TradeListingParseError::InvalidJson("discount".to_string()), EventEncodeError::InvalidKind(_) => TradeListingParseError::InvalidTag("kind".to_string()), } @@ -188,6 +195,9 @@ fn listing_from_tags( resource_area: Option<RadrootsResourceAreaRef>, plot: Option<RadrootsPlotRef>, ) -> Result<RadrootsListing, TradeListingParseError> { + if !is_d_tag_base64url(&d_tag) { + return Err(TradeListingParseError::InvalidTag(TAG_D.to_string())); + } let mut product = RadrootsListingProduct { key: String::new(), title: String::new(), @@ -518,6 +528,9 @@ fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsListingFarmRef, TradeL if pubkey.trim().is_empty() || d_tag.trim().is_empty() { return Err(TradeListingParseError::InvalidTag(TAG_A.to_string())); } + if !is_d_tag_base64url(&d_tag) { + return Err(TradeListingParseError::InvalidTag(TAG_A.to_string())); + } return Ok(RadrootsListingFarmRef { pubkey, d_tag }); } Err(TradeListingParseError::MissingTag(TAG_A.to_string())) @@ -574,6 +587,11 @@ fn parse_resource_area( TAG_RADROOTS_RESOURCE_AREA.to_string(), )); } + if !is_d_tag_base64url(&d_tag) { + return Err(TradeListingParseError::InvalidTag( + TAG_RADROOTS_RESOURCE_AREA.to_string(), + )); + } Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag })) } @@ -607,6 +625,9 @@ fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, Trade if pubkey.trim().is_empty() || d_tag.trim().is_empty() { return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string())); } + if !is_d_tag_base64url(&d_tag) { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string())); + } Ok(Some(RadrootsPlotRef { pubkey, d_tag })) } @@ -676,6 +697,50 @@ mod tests { "kg" ); } + + #[test] + fn listing_from_tags_rejects_invalid_d_tag() { + let tags = vec![ + vec!["key".into(), "coffee".into()], + vec!["title".into(), "Coffee".into()], + vec!["category".into(), "coffee".into()], + vec!["radroots:primary_bin".into(), "bin-1".into()], + vec![ + "radroots:bin".into(), + "bin-1".into(), + "1000".into(), + "g".into(), + "1".into(), + "kg".into(), + "bag".into(), + ], + vec![ + "radroots:price".into(), + "bin-1".into(), + "0.01".into(), + "USD".into(), + "1".into(), + "g".into(), + "10".into(), + "kg".into(), + ], + ]; + + let err = listing_from_tags( + &tags, + "invalid:tag".to_string(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + + assert!(matches!( + err, + TradeListingParseError::InvalidTag(tag) if tag == TAG_D + )); + } } fn clean_value(value: &str) -> Option<String> {