commit eb67767535467a1271d75bb462d3193575b6bfca
parent d268267afa1ed5e35ec5d1153911f831c5b1f47a
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Jul 2026 08:22:53 +0000
event_codec: close semantic protocol coverage
- Exercise every trade mutation tag, kind, parent, and error-mapping contract.
- Cover wrapper propagation, envelope conversion, and typed order binding failures.
- Remove redundant fallible workspace address validation after field validation.
- Exclude deliberate negative-test panic arms from semantic coverage accounting.
Diffstat:
15 files changed, 293 insertions(+), 19 deletions(-)
diff --git a/crates/event_codec/Cargo.toml b/crates/event_codec/Cargo.toml
@@ -44,3 +44,6 @@ radroots_blossom = { workspace = true, default-features = false, features = [
] }
radroots_test_fixtures = { workspace = true }
serde_json = { workspace = true, features = ["std"] }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/event_codec/src/error.rs b/crates/event_codec/src/error.rs
@@ -93,3 +93,18 @@ impl fmt::Display for EventEncodeError {
#[cfg(feature = "std")]
impl std::error::Error for EventEncodeError {}
+
+#[cfg(test)]
+mod tests {
+ use super::EventParseError;
+ use radroots_event::RadrootsEventEnvelopeError;
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn invalid_envelope_conversion_preserves_public_error_contract() {
+ let error = EventParseError::from(RadrootsEventEnvelopeError::NonCanonicalId);
+
+ assert_eq!(error.code(), "invalid_envelope");
+ assert_eq!(error.to_string(), "invalid event envelope");
+ }
+}
diff --git a/crates/event_codec/src/farm/mod.rs b/crates/event_codec/src/farm/mod.rs
@@ -116,6 +116,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(feature = "serde_json")]
fn farm_decode_rejects_empty_d_tag_and_content() {
let farm = RadrootsFarm {
@@ -151,6 +152,32 @@ mod tests {
empty_content,
crate::error::EventParseError::InvalidJson("content")
));
+
+ let with_empty_tag = farm_from_event(
+ KIND_FARM,
+ &[
+ Vec::new(),
+ vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()],
+ ],
+ &content,
+ )
+ .expect("empty unrelated tags are ignored");
+ assert_eq!(with_empty_tag.name, "Test Farm");
+
+ let parsed_error = parsed_from_event(
+ EVENT_ID.to_string(),
+ AUTHOR.to_string(),
+ 42,
+ KIND_FARM + 1,
+ content,
+ vec![vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]],
+ EVENT_SIG.to_string(),
+ )
+ .expect_err("parsed wrapper propagates decode failures");
+ assert!(matches!(
+ parsed_error,
+ crate::error::EventParseError::InvalidKind { .. }
+ ));
}
#[test]
@@ -274,6 +301,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(feature = "serde_json")]
fn farm_decode_rejects_private_location_and_ops_shapes() {
let farm = RadrootsFarm {
diff --git a/crates/event_codec/src/farm_crdt/encode.rs b/crates/event_codec/src/farm_crdt/encode.rs
@@ -1,5 +1,5 @@
#[cfg(not(feature = "std"))]
-use alloc::{string::String, vec::Vec};
+use alloc::{format, string::String, vec::Vec};
#[cfg(feature = "serde_json")]
use radroots_event::farm_crdt::KIND_FARM_CRDT_CHANGE;
@@ -12,8 +12,7 @@ use radroots_event::{
use crate::d_tag::validate_d_tag;
use crate::error::EventEncodeError;
use crate::field_helpers::{
- address_string, push_optional_tag, push_tag, validate_non_empty_base64url,
- validate_non_empty_field,
+ push_optional_tag, push_tag, validate_non_empty_base64url, validate_non_empty_field,
};
#[cfg(feature = "serde_json")]
use radroots_event::wire::RadrootsNip01EventWireParts;
@@ -32,12 +31,10 @@ pub fn farm_crdt_change_build_tags_with_author(
if let Some(author_pubkey) = author_pubkey {
validate_non_empty_field(author_pubkey, "author_pubkey")?;
}
- let workspace = address_string(
- KIND_FARM_WORKSPACE_MANIFEST,
- &change.workspace.pubkey,
- &change.workspace.d_tag,
- "workspace",
- )?;
+ let workspace = format!(
+ "{KIND_FARM_WORKSPACE_MANIFEST}:{}:{}",
+ change.workspace.pubkey, change.workspace.d_tag
+ );
let mut tags = Vec::new();
push_tag(&mut tags, TAG_H, change.farm_group_id.as_str());
push_tag(&mut tags, TAG_D, change.document_id.as_str());
diff --git a/crates/event_codec/src/farm_crdt/mod.rs b/crates/event_codec/src/farm_crdt/mod.rs
@@ -602,6 +602,7 @@ mod tests {
*tag = replacement;
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_parse_error(actual: EventParseError, expected: EventParseError) {
match (actual, expected) {
(EventParseError::MissingTag(actual), EventParseError::MissingTag(expected))
@@ -631,6 +632,7 @@ mod tests {
}
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_encode_error(actual: EventEncodeError, expected: EventEncodeError) {
match (actual, expected) {
(
diff --git a/crates/event_codec/src/farm_file/decode.rs b/crates/event_codec/src/farm_file/decode.rs
@@ -299,6 +299,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn encode_error_mapper_covers_invalid_field_tags() {
for (field, expected_tag) in [
("d_tag", TAG_D),
diff --git a/crates/event_codec/src/farm_file/encode.rs b/crates/event_codec/src/farm_file/encode.rs
@@ -19,7 +19,7 @@ use radroots_event::{
use crate::d_tag::validate_d_tag;
use crate::error::EventEncodeError;
use crate::field_helpers::{
- address_string, push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64,
+ push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64,
validate_non_empty_field,
};
use radroots_event::wire::RadrootsNip01EventWireParts;
@@ -37,12 +37,10 @@ pub fn farm_file_metadata_build_tags(
metadata: &RadrootsFarmFileMetadata,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
validate_metadata(metadata)?;
- let workspace = address_string(
- KIND_FARM_WORKSPACE_MANIFEST,
- &metadata.workspace.pubkey,
- &metadata.workspace.d_tag,
- "workspace",
- )?;
+ let workspace = format!(
+ "{KIND_FARM_WORKSPACE_MANIFEST}:{}:{}",
+ metadata.workspace.pubkey, metadata.workspace.d_tag
+ );
let mut tags = Vec::new();
push_tag(&mut tags, TAG_D, metadata.d_tag.as_str());
push_tag(&mut tags, TAG_H, metadata.farm_group_id.as_str());
diff --git a/crates/event_codec/src/farm_file/mod.rs b/crates/event_codec/src/farm_file/mod.rs
@@ -236,6 +236,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn farm_file_metadata_rejects_malformed_decode_tags() {
let parts = to_wire_parts(&sample_metadata()).expect("file metadata wire parts");
@@ -467,6 +468,7 @@ mod tests {
.collect()
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_encode_error(actual: EventEncodeError, expected: EventEncodeError) {
match (actual, expected) {
(
diff --git a/crates/event_codec/src/farm_workspace/mod.rs b/crates/event_codec/src/farm_workspace/mod.rs
@@ -438,6 +438,7 @@ mod tests {
);
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_parse_error(actual: EventParseError, expected: EventParseError) {
match (actual, expected) {
(EventParseError::MissingTag(actual), EventParseError::MissingTag(expected))
@@ -467,6 +468,7 @@ mod tests {
}
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_encode_error(actual: EventEncodeError, expected: EventEncodeError) {
match (actual, expected) {
(
diff --git a/crates/event_codec/src/group/mod.rs b/crates/event_codec/src/group/mod.rs
@@ -589,6 +589,7 @@ mod tests {
}
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_empty_required<T>(result: Result<T, EventEncodeError>, field: &'static str) {
let err = match result {
Ok(_) => panic!("expected empty required field error"),
diff --git a/crates/event_codec/src/http_auth/mod.rs b/crates/event_codec/src/http_auth/mod.rs
@@ -149,6 +149,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn http_auth_rejects_duplicate_security_tags() {
let auth = RadrootsHttpAuth {
url: "https://media.example.invalid/upload".to_string(),
diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs
@@ -1,4 +1,5 @@
#![cfg_attr(not(feature = "std"), no_std)]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![forbid(unsafe_code)]
#[cfg(not(feature = "std"))]
extern crate alloc;
diff --git a/crates/event_codec/src/order/decode.rs b/crates/event_codec/src/order/decode.rs
@@ -961,6 +961,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn order_parse_rejects_payload_and_chain_binding_mismatches() {
let mut request_payload = order_request();
request_payload.order_id = order_id("other-order");
@@ -1004,9 +1005,31 @@ mod tests {
order_request_from_event(&request_event).unwrap_err(),
RadrootsOrderEnvelopeParseError::PayloadBindingMismatch("listing_addr")
);
+
+ let mut decision_payload = order_decision();
+ decision_payload.order_id = order_id("other-order");
+ let decision_built =
+ order_decision_event_build(&event_id('1'), &event_id('9'), &order_decision()).unwrap();
+ let decision_event = event_envelope(
+ seller_pubkey_wire(),
+ decision_built.kind,
+ decision_built.tags,
+ serde_json::to_string(&RadrootsOrderEnvelope::new(
+ RadrootsOrderEventType::OrderDecision,
+ listing_addr_wire(),
+ "order-1",
+ &decision_payload,
+ ))
+ .unwrap(),
+ );
+ assert_eq!(
+ order_decision_from_event(&decision_event).unwrap_err(),
+ RadrootsOrderEnvelopeParseError::PayloadBindingMismatch("order_id")
+ );
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn order_event_context_and_parse_error_mapping_cover_missing_context() {
let err = order_event_context_from_tags(
RadrootsOrderEventType::OrderRequested,
@@ -1074,6 +1097,10 @@ mod tests {
)),
RadrootsOrderEnvelopeParseError::InvalidTag("json")
);
+ assert_eq!(
+ map_tag_parse_error_for_order_envelope(crate::error::EventParseError::InvalidEnvelope),
+ RadrootsOrderEnvelopeParseError::InvalidTag("event_envelope")
+ );
}
#[test]
diff --git a/crates/event_codec/src/order/encode.rs b/crates/event_codec/src/order/encode.rs
@@ -366,6 +366,7 @@ mod tests {
assert_empty_required(invalid_listing_event, "listing_event.id");
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_empty_required(error: EventEncodeError, field: &'static str) {
match error {
EventEncodeError::EmptyRequiredField(found) => assert_eq!(found, field),
@@ -373,6 +374,7 @@ mod tests {
}
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_invalid_field(error: EventEncodeError, field: &'static str) {
match error {
EventEncodeError::InvalidField(found) => assert_eq!(found, field),
diff --git a/crates/event_codec/src/trade/mod.rs b/crates/event_codec/src/trade/mod.rs
@@ -8,7 +8,7 @@ use alloc::{
#[cfg(feature = "serde_json")]
use radroots_event::{
RadrootsEventEnvelope,
- ids::{RadrootsDTag, RadrootsTradeMutationId},
+ ids::RadrootsTradeMutationId,
kinds::is_trade_mutation_event_kind,
tags::{TAG_D, TAG_E},
trade::{
@@ -140,8 +140,6 @@ fn validate_trade_mutation_tags(
if parents != envelope.parent_mutation_ids {
return Err(RadrootsTradeMutationParseError::ParentTagsMismatch);
}
- RadrootsDTag::parse(trade_id)
- .map_err(|_| RadrootsTradeMutationParseError::InvalidTag(TAG_D))?;
Ok(())
}
@@ -354,4 +352,200 @@ mod tests {
.unwrap();
assert_eq!(envelope.contract_id, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID);
}
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_mutation_codec_rejects_all_invalid_wire_shapes() {
+ let parse_errors = [
+ RadrootsTradeMutationParseError::InvalidKind(1),
+ RadrootsTradeMutationParseError::MissingTag("contract"),
+ RadrootsTradeMutationParseError::InvalidTag("contract"),
+ RadrootsTradeMutationParseError::ContractTagMismatch,
+ RadrootsTradeMutationParseError::TradeIdTagMismatch,
+ RadrootsTradeMutationParseError::CounterpartyTagMismatch,
+ RadrootsTradeMutationParseError::ParentTagsMismatch,
+ RadrootsTradeMutationParseError::KindContractMismatch,
+ RadrootsTradeMutationParseError::Canonical(RadrootsTradeProtocolError::MissingLines),
+ ];
+ for error in parse_errors {
+ assert!(!error.to_string().is_empty());
+ }
+ let canonical_error =
+ RadrootsTradeMutationParseError::from(RadrootsTradeProtocolError::MissingLines);
+ assert!(matches!(
+ canonical_error,
+ RadrootsTradeMutationParseError::Canonical(_)
+ ));
+
+ let mut tags = trade_mutation_tags(&proposal()).unwrap();
+ *tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some("contract"))
+ .unwrap() = vec!["contract".into(), "wrong-contract".into()];
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(),
+ RadrootsTradeMutationParseError::ContractTagMismatch
+ );
+
+ let mut tags = trade_mutation_tags(&proposal()).unwrap();
+ *tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some(TAG_D))
+ .unwrap() = vec![TAG_D.into(), "other-trade".into()];
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(),
+ RadrootsTradeMutationParseError::TradeIdTagMismatch
+ );
+
+ let mut tags = trade_mutation_tags(&proposal()).unwrap();
+ *tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some("p"))
+ .unwrap() = vec!["p".into(), hex_64('c')];
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(),
+ RadrootsTradeMutationParseError::CounterpartyTagMismatch
+ );
+
+ let mut missing_parent_value = trade_mutation_tags(&proposal()).unwrap();
+ missing_parent_value.push(vec![TAG_E.into()]);
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &missing_parent_value).unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidTag(TAG_E)
+ );
+
+ let mut invalid_parent = trade_mutation_tags(&proposal()).unwrap();
+ invalid_parent.push(vec![TAG_E.into(), "not-an-event-id".into()]);
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &invalid_parent).unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidTag(TAG_E)
+ );
+
+ let parent = RadrootsTradeMutationId::parse(hex_64('9')).unwrap();
+ let mut parent_envelope = proposal();
+ parent_envelope.parent_mutation_ids.push(parent.clone());
+ let parent_tags = trade_mutation_tags(&parent_envelope).unwrap();
+ validate_trade_mutation_tags(&parent_envelope, &parent_tags).unwrap();
+
+ let mut mismatched_parent = trade_mutation_tags(&proposal()).unwrap();
+ mismatched_parent.push(vec![TAG_E.into(), parent.to_string()]);
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &mismatched_parent).unwrap_err(),
+ RadrootsTradeMutationParseError::ParentTagsMismatch
+ );
+
+ assert_eq!(
+ required_tag_value(&[], "contract").unwrap_err(),
+ RadrootsTradeMutationParseError::MissingTag("contract")
+ );
+ assert_eq!(
+ required_tag_value(&[vec!["contract".into()]], "contract").unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidTag("contract")
+ );
+ assert_eq!(
+ required_tag_value(&[vec!["contract".into(), " ".into()]], "contract",).unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidTag("contract")
+ );
+ assert!(matches!(
+ push_tag(&mut Vec::new(), "contract", " ".into()).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("contract")
+ ));
+
+ let built = trade_mutation_event_build(proposal()).unwrap();
+ let invalid_kind = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: hex_64('e'),
+ author: hex_64('a'),
+ created_at: 1_799_000_000,
+ kind: 1,
+ tags: built.tags.clone(),
+ content: built.content.clone(),
+ sig: core::iter::repeat_n('f', 128).collect(),
+ })
+ .unwrap();
+ assert_eq!(
+ trade_mutation_from_event(&invalid_kind).unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidKind(1)
+ );
+
+ let kind_contract_mismatch = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: hex_64('e'),
+ author: hex_64('a'),
+ created_at: 1_799_000_000,
+ kind: radroots_event::kinds::KIND_TRADE_DECISION,
+ tags: built.tags,
+ content: built.content,
+ sig: core::iter::repeat_n('f', 128).collect(),
+ })
+ .unwrap();
+ assert_eq!(
+ trade_mutation_from_event(&kind_contract_mismatch).unwrap_err(),
+ RadrootsTradeMutationParseError::KindContractMismatch
+ );
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_protocol_errors_map_to_stable_encode_categories() {
+ let id_error = RadrootsTradeMutationId::parse("invalid").unwrap_err();
+ let invalid_field_errors = [
+ RadrootsTradeProtocolError::ContractMismatch {
+ expected: "expected",
+ actual: "actual".into(),
+ },
+ RadrootsTradeProtocolError::InvalidField("field"),
+ RadrootsTradeProtocolError::InvalidIdentifier {
+ field: "id",
+ error: id_error,
+ },
+ RadrootsTradeProtocolError::InvalidInitialParents,
+ RadrootsTradeProtocolError::MissingParentMutation,
+ RadrootsTradeProtocolError::TooManyParents { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::UnsortedParents,
+ RadrootsTradeProtocolError::DuplicateParent,
+ RadrootsTradeProtocolError::SelfParent,
+ RadrootsTradeProtocolError::MissingLines,
+ RadrootsTradeProtocolError::TooManyLines { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::TooManyAdjustments { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::UnsupportedNumber,
+ RadrootsTradeProtocolError::ContentTooLarge { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::InvalidTimeRange,
+ RadrootsTradeProtocolError::MissingReservationCommitments,
+ RadrootsTradeProtocolError::MissingCancellationTarget,
+ RadrootsTradeProtocolError::CandidateIdMismatch {
+ declared: "declared".into(),
+ computed: "computed".into(),
+ },
+ RadrootsTradeProtocolError::MutationIdMismatch {
+ declared: "declared".into(),
+ computed: "computed".into(),
+ },
+ RadrootsTradeProtocolError::InvalidSchemaVersion {
+ expected: 1,
+ actual: 2,
+ },
+ ];
+ for error in invalid_field_errors {
+ assert!(matches!(
+ map_trade_protocol_error_to_encode_error(error),
+ EventEncodeError::InvalidField("trade_mutation")
+ ));
+ }
+
+ assert!(matches!(
+ map_trade_protocol_error_to_encode_error(RadrootsTradeProtocolError::EmptyField(
+ "field"
+ )),
+ EventEncodeError::EmptyRequiredField("field")
+ ));
+ for error in [
+ RadrootsTradeProtocolError::DuplicateKey("key".into()),
+ RadrootsTradeProtocolError::InvalidJson("json".into()),
+ RadrootsTradeProtocolError::NonCanonicalJson,
+ ] {
+ assert!(matches!(
+ map_trade_protocol_error_to_encode_error(error),
+ EventEncodeError::Json
+ ));
+ }
+ }
}