commit cff6ab4bffb521fb7c498c7fe9a64112125cc03f
parent e914e45a41f670f020218534a480f8c5957fef51
Author: triesap <tyson@radroots.org>
Date: Thu, 11 Jun 2026 17:06:16 -0700
events_codec: add field codec helpers
- add crate-private Radroots address format and parse helpers
- add lowercase SHA-256 and base64url payload validators
- add shared tag construction and lookup helpers for Field codecs
- test accepted and rejected helper inputs under serde_json
Diffstat:
2 files changed, 397 insertions(+), 0 deletions(-)
diff --git a/crates/events_codec/src/field_helpers.rs b/crates/events_codec/src/field_helpers.rs
@@ -0,0 +1,396 @@
+#[cfg(not(feature = "std"))]
+use alloc::{
+ format,
+ string::{String, ToString},
+ vec,
+ vec::Vec,
+};
+
+use crate::d_tag::validate_d_tag;
+#[cfg(feature = "serde_json")]
+use crate::d_tag::validate_d_tag_tag;
+use crate::error::EventEncodeError;
+#[cfg(feature = "serde_json")]
+use crate::error::EventParseError;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct RadrootsAddress {
+ pub kind: u32,
+ pub pubkey: String,
+ pub d_tag: String,
+}
+
+pub(crate) fn address_string(
+ kind: u32,
+ pubkey: &str,
+ d_tag: &str,
+ field: &'static str,
+) -> Result<String, EventEncodeError> {
+ validate_non_empty_field(pubkey, field)?;
+ validate_d_tag(d_tag, field)?;
+ Ok(format!("{kind}:{pubkey}:{d_tag}"))
+}
+
+#[cfg(feature = "serde_json")]
+pub(crate) fn parse_address_tag(
+ value: &str,
+ tag: &'static str,
+) -> Result<RadrootsAddress, EventParseError> {
+ let mut parts = value.split(':');
+ let kind = parts
+ .next()
+ .ok_or(EventParseError::InvalidTag(tag))?
+ .parse::<u32>()
+ .map_err(|err| EventParseError::InvalidNumber(tag, err))?;
+ let pubkey = parts
+ .next()
+ .map(ToString::to_string)
+ .ok_or(EventParseError::InvalidTag(tag))?;
+ let d_tag = parts
+ .next()
+ .map(ToString::to_string)
+ .ok_or(EventParseError::InvalidTag(tag))?;
+ if parts.next().is_some() {
+ return Err(EventParseError::InvalidTag(tag));
+ }
+ validate_non_empty_tag_value(&pubkey, tag)?;
+ validate_d_tag_tag(&d_tag, tag)?;
+ Ok(RadrootsAddress {
+ kind,
+ pubkey,
+ d_tag,
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub(crate) fn parse_address_tag_with_kind(
+ value: &str,
+ expected_kind: u32,
+ tag: &'static str,
+) -> Result<RadrootsAddress, EventParseError> {
+ let address = parse_address_tag(value, tag)?;
+ if address.kind != expected_kind {
+ return Err(EventParseError::InvalidTag(tag));
+ }
+ Ok(address)
+}
+
+pub(crate) fn is_lowercase_hex_64(value: &str) -> bool {
+ value.len() == 64
+ && value
+ .as_bytes()
+ .iter()
+ .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
+}
+
+pub(crate) fn validate_lowercase_hex_64(
+ value: &str,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ if is_lowercase_hex_64(value) {
+ Ok(())
+ } else {
+ Err(EventEncodeError::InvalidField(field))
+ }
+}
+
+#[cfg(feature = "serde_json")]
+pub(crate) fn validate_lowercase_hex_64_tag(
+ value: &str,
+ tag: &'static str,
+) -> Result<(), EventParseError> {
+ if is_lowercase_hex_64(value) {
+ Ok(())
+ } else {
+ Err(EventParseError::InvalidTag(tag))
+ }
+}
+
+pub(crate) fn is_non_empty_base64url(value: &str) -> bool {
+ !value.is_empty()
+ && 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_non_empty_base64url(
+ value: &str,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ if is_non_empty_base64url(value) {
+ Ok(())
+ } else {
+ Err(EventEncodeError::InvalidField(field))
+ }
+}
+
+#[cfg(feature = "serde_json")]
+pub(crate) fn validate_non_empty_base64url_tag(
+ value: &str,
+ tag: &'static str,
+) -> Result<(), EventParseError> {
+ if is_non_empty_base64url(value) {
+ Ok(())
+ } else {
+ Err(EventParseError::InvalidTag(tag))
+ }
+}
+
+pub(crate) fn validate_non_empty_field(
+ value: &str,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ if value.trim().is_empty() {
+ Err(EventEncodeError::EmptyRequiredField(field))
+ } else {
+ Ok(())
+ }
+}
+
+#[cfg(feature = "serde_json")]
+pub(crate) fn validate_non_empty_tag_value(
+ value: &str,
+ tag: &'static str,
+) -> Result<(), EventParseError> {
+ if value.trim().is_empty() {
+ Err(EventParseError::InvalidTag(tag))
+ } else {
+ Ok(())
+ }
+}
+
+pub(crate) fn push_tag(tags: &mut Vec<Vec<String>>, key: &str, value: impl Into<String>) {
+ tags.push(vec![key.to_string(), value.into()]);
+}
+
+pub(crate) fn push_optional_tag(tags: &mut Vec<Vec<String>>, key: &str, value: Option<&str>) {
+ if let Some(value) = value {
+ if !value.trim().is_empty() {
+ push_tag(tags, key, value);
+ }
+ }
+}
+
+pub(crate) fn push_tag_values<I, S>(tags: &mut Vec<Vec<String>>, key: &str, values: I)
+where
+ I: IntoIterator<Item = S>,
+ S: Into<String>,
+{
+ let mut tag = vec![key.to_string()];
+ tag.extend(values.into_iter().map(Into::into));
+ tags.push(tag);
+}
+
+#[cfg(feature = "serde_json")]
+pub(crate) fn required_tag_value(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<String, EventParseError> {
+ tags.iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+ .ok_or(EventParseError::MissingTag(key))
+ .and_then(|tag| {
+ tag.get(1)
+ .map(ToString::to_string)
+ .ok_or(EventParseError::InvalidTag(key))
+ })
+ .and_then(|value| {
+ validate_non_empty_tag_value(&value, key)?;
+ Ok(value)
+ })
+}
+
+#[cfg(feature = "serde_json")]
+pub(crate) fn optional_tag_value(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<String>, EventParseError> {
+ let Some(tag) = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+ else {
+ return Ok(None);
+ };
+ let value = tag
+ .get(1)
+ .map(ToString::to_string)
+ .ok_or(EventParseError::InvalidTag(key))?;
+ validate_non_empty_tag_value(&value, key)?;
+ Ok(Some(value))
+}
+
+#[cfg(feature = "serde_json")]
+pub(crate) fn tag_values(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Vec<String>, EventParseError> {
+ tags.iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+ .map(|tag| {
+ tag.get(1)
+ .map(ToString::to_string)
+ .ok_or(EventParseError::InvalidTag(key))
+ .and_then(|value| {
+ validate_non_empty_tag_value(&value, key)?;
+ Ok(value)
+ })
+ })
+ .collect()
+}
+
+#[cfg(feature = "serde_json")]
+pub(crate) fn require_empty_content(
+ content: &str,
+ field: &'static str,
+) -> Result<(), EventParseError> {
+ if content.is_empty() {
+ Ok(())
+ } else {
+ Err(EventParseError::InvalidJson(field))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const VALID_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+ const VALID_HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+ #[test]
+ fn address_string_formats_valid_radroots_address() {
+ let address = address_string(30078, "workspace_pubkey", VALID_D_TAG, "workspace")
+ .expect("valid address");
+
+ assert_eq!(address, "30078:workspace_pubkey:AAAAAAAAAAAAAAAAAAAAAA");
+ }
+
+ #[test]
+ fn address_string_rejects_empty_pubkey_and_bad_d_tag() {
+ assert!(matches!(
+ address_string(30078, "", VALID_D_TAG, "workspace"),
+ Err(EventEncodeError::EmptyRequiredField("workspace"))
+ ));
+ assert!(matches!(
+ address_string(30078, "workspace_pubkey", "bad", "workspace"),
+ Err(EventEncodeError::InvalidField("workspace"))
+ ));
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn address_parser_accepts_valid_radroots_address() {
+ let address = parse_address_tag("30078:workspace_pubkey:AAAAAAAAAAAAAAAAAAAAAA", "a")
+ .expect("valid address");
+
+ assert_eq!(address.kind, 30078);
+ assert_eq!(address.pubkey, "workspace_pubkey");
+ assert_eq!(address.d_tag, VALID_D_TAG);
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn address_parser_rejects_invalid_radroots_addresses() {
+ assert!(matches!(
+ parse_address_tag("30078:workspace_pubkey", "a"),
+ Err(EventParseError::InvalidTag("a"))
+ ));
+ assert!(matches!(
+ parse_address_tag("30078::AAAAAAAAAAAAAAAAAAAAAA", "a"),
+ Err(EventParseError::InvalidTag("a"))
+ ));
+ assert!(matches!(
+ parse_address_tag("bad:workspace_pubkey:AAAAAAAAAAAAAAAAAAAAAA", "a"),
+ Err(EventParseError::InvalidNumber("a", _))
+ ));
+ assert!(matches!(
+ parse_address_tag("30078:workspace_pubkey:bad", "a"),
+ Err(EventParseError::InvalidTag("a"))
+ ));
+ assert!(matches!(
+ parse_address_tag_with_kind("78:workspace_pubkey:AAAAAAAAAAAAAAAAAAAAAA", 30078, "a"),
+ Err(EventParseError::InvalidTag("a"))
+ ));
+ }
+
+ #[test]
+ fn lowercase_hex_hash_validation_accepts_only_sha256_shape() {
+ assert!(is_lowercase_hex_64(VALID_HASH));
+ assert!(!is_lowercase_hex_64(
+ "0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef"
+ ));
+ assert!(!is_lowercase_hex_64(
+ "0123456789xyzdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ ));
+ assert!(!is_lowercase_hex_64("0123456789abcdef"));
+ assert!(matches!(
+ validate_lowercase_hex_64("0123456789abcdef", "payload"),
+ Err(EventEncodeError::InvalidField("payload"))
+ ));
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn lowercase_hex_tag_validation_maps_to_parse_error() {
+ assert!(validate_lowercase_hex_64_tag(VALID_HASH, "x").is_ok());
+ assert!(matches!(
+ validate_lowercase_hex_64_tag("0123456789abcdef", "x"),
+ Err(EventParseError::InvalidTag("x"))
+ ));
+ }
+
+ #[test]
+ fn base64url_validation_accepts_non_empty_unpadded_payloads() {
+ assert!(is_non_empty_base64url("abc-DEF_012"));
+ assert!(!is_non_empty_base64url(""));
+ assert!(!is_non_empty_base64url("abc="));
+ assert!(!is_non_empty_base64url("abc/def"));
+ assert!(matches!(
+ validate_non_empty_base64url("abc=", "encoded_change"),
+ Err(EventEncodeError::InvalidField("encoded_change"))
+ ));
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn tag_helpers_parse_required_optional_and_repeated_values() {
+ let tags = vec![
+ vec!["h".to_string(), "group".to_string()],
+ vec!["t".to_string(), "radroots:farm:crdt".to_string()],
+ vec!["t".to_string(), "task".to_string()],
+ ];
+
+ assert_eq!(required_tag_value(&tags, "h").unwrap(), "group");
+ assert_eq!(optional_tag_value(&tags, "missing").unwrap(), None);
+ assert_eq!(
+ tag_values(&tags, "t").unwrap(),
+ vec!["radroots:farm:crdt".to_string(), "task".to_string()]
+ );
+ }
+
+ #[test]
+ fn tag_helpers_build_simple_and_repeated_tags() {
+ let mut tags = Vec::new();
+
+ push_tag(&mut tags, "h", "group");
+ push_optional_tag(&mut tags, "p", Some("pubkey"));
+ push_optional_tag(&mut tags, "p", Some(""));
+ push_tag_values(&mut tags, "roles", ["member", "admin"]);
+
+ assert_eq!(
+ tags,
+ vec![
+ vec!["h".to_string(), "group".to_string()],
+ vec!["p".to_string(), "pubkey".to_string()],
+ vec![
+ "roles".to_string(),
+ "member".to_string(),
+ "admin".to_string()
+ ],
+ ]
+ );
+ }
+}
diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs
@@ -6,6 +6,7 @@ extern crate alloc;
pub mod d_tag;
pub mod error;
pub mod event_ref;
+mod field_helpers;
pub mod job;
pub mod parsed;
pub mod profile;