v5.rs (61061B)
1 //! Daemon transport-publish wire contract generation 5. 2 //! 3 //! The types in this module are passive serialized DTOs. Transport-native 4 //! targets, relay clients, delivery execution, and conversions belong to their 5 //! owning transport and host packages. 6 7 use alloc::{borrow::ToOwned, collections::BTreeSet, string::String, vec, vec::Vec}; 8 use core::fmt; 9 10 use crate::schema::{Descriptor as SchemaDescriptor, ModuleVersion, Registry}; 11 12 /// Stable daemon API/schema identity. 13 pub const API_VERSION: &str = "radrootsd.transport_publish.v5"; 14 /// Stable daemon identity. 15 pub const DAEMON_NAME: &str = "radrootsd"; 16 /// Capabilities RPC method. 17 pub const METHOD_CAPABILITIES: &str = "transport.publish.capabilities"; 18 /// Event publication RPC method. 19 pub const METHOD_EVENT: &str = "transport.publish.event"; 20 /// Job lookup RPC method. 21 pub const METHOD_JOB_GET: &str = "transport.publish.job.get"; 22 /// Job listing RPC method. 23 pub const METHOD_JOB_LIST: &str = "transport.publish.job.list"; 24 /// Canonical V5 Reticulum endpoint identity. 25 pub const RETICULUM_ENDPOINT_URI: &str = "reticulum:local"; 26 /// Stable V5 Reticulum unavailability message. 27 pub const RETICULUM_UNAVAILABLE_MESSAGE: &str = concat!( 28 "Reticulum transport is configured, ", 29 "but this build does not implement Reticulum delivery." 30 ); 31 32 #[derive(Clone, Debug, PartialEq, Eq)] 33 pub enum Error { 34 InvalidHexField { 35 field: &'static str, 36 expected_len: usize, 37 }, 38 EmptyRawEventJson, 39 EmptyTag { 40 index: usize, 41 }, 42 EmptyIdempotencyKey, 43 EmptyTransportKind { 44 index: usize, 45 }, 46 InvalidTransportKind { 47 index: usize, 48 }, 49 EmptyEndpointUri { 50 index: usize, 51 }, 52 InvalidEndpointUri { 53 index: usize, 54 }, 55 EmptyTargetScope { 56 index: usize, 57 }, 58 InvalidTargetScope { 59 index: usize, 60 }, 61 EmptyTargetLabel { 62 index: usize, 63 }, 64 InvalidTargetLabel { 65 index: usize, 66 }, 67 InvalidReticulumBehavior { 68 index: usize, 69 }, 70 InvalidTimeoutMs, 71 InvalidReticulumEndpoint { 72 index: usize, 73 }, 74 DuplicateTarget { 75 index: usize, 76 }, 77 TargetLimitExceeded { 78 max: usize, 79 actual: usize, 80 }, 81 EmptyTargetSet, 82 InvalidQuorum, 83 EmptyRequiredTargetSet, 84 DuplicateRequiredTargetFingerprint { 85 index: usize, 86 }, 87 RequiredTargetNotInTargetSet { 88 index: usize, 89 }, 90 EmptyPrincipalId, 91 EmptyJobId, 92 InvalidJobTargetCount { 93 expected: usize, 94 actual: usize, 95 }, 96 InvalidJobAcknowledgedCount { 97 expected: usize, 98 actual: usize, 99 }, 100 InvalidJobRetryableCount { 101 expected: usize, 102 actual: usize, 103 }, 104 InvalidJobTerminalCount { 105 expected: usize, 106 actual: usize, 107 }, 108 InvalidJobTerminalState, 109 InvalidJobDeliverySatisfiedState, 110 InvalidJobCompletedAt, 111 InvalidJobStatusState, 112 InvalidExplicitTargetOutcome { 113 index: usize, 114 }, 115 InvalidTargetOutcomeKind { 116 index: usize, 117 }, 118 InvalidTargetSource { 119 index: usize, 120 }, 121 InvalidReticulumOutcome { 122 index: usize, 123 }, 124 } 125 126 impl fmt::Display for Error { 127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 128 match self { 129 Self::InvalidHexField { 130 field, 131 expected_len, 132 } => write!(f, "{field} must be {expected_len} lowercase hex characters"), 133 Self::EmptyRawEventJson => f.write_str("raw_event_json must not be empty"), 134 Self::EmptyTag { index } => write!(f, "tag {index} must not be empty"), 135 Self::EmptyIdempotencyKey => f.write_str("idempotency key must not be empty"), 136 Self::EmptyTransportKind { index } => { 137 write!(f, "transport target {index} kind must not be empty") 138 } 139 Self::InvalidTransportKind { index } => { 140 write!( 141 f, 142 "transport target {index} kind must be canonical lowercase" 143 ) 144 } 145 Self::EmptyEndpointUri { index } => { 146 write!(f, "transport target {index} endpoint_uri must not be empty") 147 } 148 Self::InvalidEndpointUri { index } => { 149 write!(f, "transport target {index} endpoint_uri is invalid") 150 } 151 Self::EmptyTargetScope { index } => { 152 write!(f, "transport target {index} target_scope must not be empty") 153 } 154 Self::InvalidTargetScope { index } => { 155 write!(f, "transport target {index} target_scope must be canonical") 156 } 157 Self::EmptyTargetLabel { index } => { 158 write!(f, "transport target {index} target_label must not be empty") 159 } 160 Self::InvalidTargetLabel { index } => { 161 write!(f, "transport target {index} target_label is invalid") 162 } 163 Self::InvalidReticulumBehavior { index } => write!( 164 f, 165 "transport target {index} reticulum_behavior is only valid for Reticulum targets" 166 ), 167 Self::InvalidTimeoutMs => f.write_str("timeout_ms must be greater than zero"), 168 Self::InvalidReticulumEndpoint { index } => write!( 169 f, 170 "transport target {index} Reticulum endpoint must be {RETICULUM_ENDPOINT_URI}" 171 ), 172 Self::DuplicateTarget { index } => { 173 write!(f, "transport target {index} duplicates an earlier target") 174 } 175 Self::TargetLimitExceeded { max, actual } => { 176 write!(f, "transport target count {actual} exceeds limit {max}") 177 } 178 Self::EmptyTargetSet => f.write_str("transport publish target set must not be empty"), 179 Self::InvalidQuorum => f.write_str("delivery quorum must be greater than zero"), 180 Self::EmptyRequiredTargetSet => { 181 f.write_str("delivery required target set must not be empty") 182 } 183 Self::DuplicateRequiredTargetFingerprint { index } => { 184 write!( 185 f, 186 "delivery required target {index} duplicates an earlier fingerprint" 187 ) 188 } 189 Self::RequiredTargetNotInTargetSet { index } => { 190 write!( 191 f, 192 "delivery required target {index} is not in the target set" 193 ) 194 } 195 Self::EmptyPrincipalId => f.write_str("principal id must not be empty"), 196 Self::EmptyJobId => f.write_str("job id must not be empty"), 197 Self::InvalidJobTargetCount { expected, actual } => write!( 198 f, 199 "job target_count {actual} does not match {expected} target outcomes" 200 ), 201 Self::InvalidJobAcknowledgedCount { expected, actual } => write!( 202 f, 203 "job acknowledged_count {actual} does not match {expected} target outcomes" 204 ), 205 Self::InvalidJobRetryableCount { expected, actual } => write!( 206 f, 207 "job retryable_count {actual} does not match {expected} target outcomes" 208 ), 209 Self::InvalidJobTerminalCount { expected, actual } => write!( 210 f, 211 "job terminal_count {actual} does not match {expected} target outcomes" 212 ), 213 Self::InvalidJobTerminalState => f.write_str("job terminal flag does not match status"), 214 Self::InvalidJobDeliverySatisfiedState => { 215 f.write_str("job delivery_satisfied flag does not match status") 216 } 217 Self::InvalidJobCompletedAt => { 218 f.write_str("job completed_at_ms does not match status or request time") 219 } 220 Self::InvalidJobStatusState => f.write_str("job status does not match target outcomes"), 221 Self::InvalidExplicitTargetOutcome { index } => write!( 222 f, 223 "transport target outcome {index} does not match explicit target policy" 224 ), 225 Self::InvalidTargetOutcomeKind { index } => { 226 write!( 227 f, 228 "transport target outcome {index} kind is not valid for its transport" 229 ) 230 } 231 Self::InvalidTargetSource { index } => { 232 write!( 233 f, 234 "transport target outcome {index} source does not match transport kind" 235 ) 236 } 237 Self::InvalidReticulumOutcome { index } => write!( 238 f, 239 "transport target outcome {index} Reticulum must be unavailable or deferred" 240 ), 241 } 242 } 243 } 244 245 #[cfg(feature = "std")] 246 impl std::error::Error for Error {} 247 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 248 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 249 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] 250 pub enum ReticulumBehavior { 251 #[default] 252 RejectDeliveryAttempts, 253 DeferDeliveryPlans, 254 } 255 256 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 257 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 258 #[derive(Clone, Debug, PartialEq, Eq)] 259 pub struct Target { 260 pub transport_kind: String, 261 pub endpoint_uri: String, 262 #[cfg_attr( 263 feature = "serde", 264 serde(default, skip_serializing_if = "Option::is_none") 265 )] 266 pub target_scope: Option<String>, 267 #[cfg_attr( 268 feature = "serde", 269 serde(default, skip_serializing_if = "Option::is_none") 270 )] 271 pub target_label: Option<String>, 272 #[cfg_attr( 273 feature = "serde", 274 serde(default, skip_serializing_if = "Option::is_none") 275 )] 276 pub reticulum_behavior: Option<ReticulumBehavior>, 277 } 278 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 279 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 280 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 281 pub enum NostrTargetSourcePolicy { 282 ExplicitOnly, 283 RequestThenAuthorWriteThenDaemonDefault, 284 AuthorWriteThenDaemonDefault, 285 DaemonDefaultOnly, 286 } 287 288 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 289 #[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))] 290 #[derive(Clone, Debug, PartialEq, Eq)] 291 pub enum TargetPolicy { 292 ExplicitTargets { 293 targets: Vec<Target>, 294 }, 295 Nostr { 296 source_policy: NostrTargetSourcePolicy, 297 #[cfg_attr(feature = "serde", serde(default))] 298 relay_urls: Vec<String>, 299 }, 300 } 301 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 302 #[cfg_attr(feature = "serde", serde(tag = "mode", rename_all = "snake_case"))] 303 #[derive(Clone, Debug, PartialEq, Eq)] 304 pub enum DeliveryPolicy { 305 Any, 306 All, 307 Quorum { quorum: usize }, 308 RequiredTargets { targets: Vec<TargetFingerprint> }, 309 } 310 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 311 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 312 #[derive(Clone, Debug, PartialEq, Eq)] 313 pub struct EventRequest { 314 pub raw_event_json: String, 315 pub target_policy: TargetPolicy, 316 pub delivery_policy: DeliveryPolicy, 317 #[cfg_attr( 318 feature = "serde", 319 serde(default, skip_serializing_if = "Option::is_none") 320 )] 321 pub idempotency_key: Option<String>, 322 #[cfg_attr( 323 feature = "serde", 324 serde(default, skip_serializing_if = "Option::is_none") 325 )] 326 pub timeout_ms: Option<u64>, 327 } 328 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 329 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 330 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 331 pub enum JobStatus { 332 Accepted, 333 Publishing, 334 DeliverySatisfied, 335 DeliveryUnsatisfiedRetryable, 336 DeliveryUnsatisfiedTerminal, 337 DeliveryDeferred, 338 DeliveryDeferredUntilImplemented, 339 Rejected, 340 } 341 342 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 343 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 344 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 345 pub enum OutcomeKind { 346 Accepted, 347 DuplicateAccepted, 348 Blocked, 349 RateLimited, 350 Invalid, 351 PowRequired, 352 Restricted, 353 AuthRequired, 354 Muted, 355 Unsupported, 356 PaymentRequired, 357 Error, 358 Timeout, 359 ConnectionFailed, 360 TargetRejected, 361 SkippedAlreadyAccepted, 362 DeferredUntilImplemented, 363 Unknown, 364 } 365 366 impl OutcomeKind { 367 pub fn counts_toward_accepted_delivery(self) -> bool { 368 matches!( 369 self, 370 Self::Accepted | Self::DuplicateAccepted | Self::SkippedAlreadyAccepted 371 ) 372 } 373 374 pub fn is_retryable(self) -> bool { 375 matches!( 376 self, 377 Self::RateLimited 378 | Self::PowRequired 379 | Self::AuthRequired 380 | Self::Error 381 | Self::Timeout 382 | Self::ConnectionFailed 383 | Self::Unknown 384 ) 385 } 386 387 pub fn is_terminal_failure(self) -> bool { 388 matches!( 389 self, 390 Self::Blocked 391 | Self::Invalid 392 | Self::Restricted 393 | Self::Muted 394 | Self::Unsupported 395 | Self::PaymentRequired 396 | Self::TargetRejected 397 ) 398 } 399 400 pub fn is_deferred_until_implemented(self) -> bool { 401 matches!(self, Self::DeferredUntilImplemented) 402 } 403 } 404 405 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 406 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 407 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 408 pub enum TargetSource { 409 Request, 410 NostrAuthorWrite, 411 DaemonDefault, 412 Reticulum, 413 } 414 415 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 416 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 417 #[derive(Clone, Debug, PartialEq, Eq)] 418 pub struct TargetOutcome { 419 pub transport_kind: String, 420 pub endpoint_uri: String, 421 #[cfg_attr( 422 feature = "serde", 423 serde(default, skip_serializing_if = "Option::is_none") 424 )] 425 pub target_scope: Option<String>, 426 #[cfg_attr( 427 feature = "serde", 428 serde(default, skip_serializing_if = "Option::is_none") 429 )] 430 pub target_label: Option<String>, 431 pub source: TargetSource, 432 pub attempted: bool, 433 pub outcome_kind: OutcomeKind, 434 #[cfg_attr( 435 feature = "serde", 436 serde(default, skip_serializing_if = "Option::is_none") 437 )] 438 pub message: Option<String>, 439 #[cfg_attr( 440 feature = "serde", 441 serde(default, skip_serializing_if = "Option::is_none") 442 )] 443 pub latency_ms: Option<u64>, 444 } 445 446 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 447 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 448 #[derive(Clone, Debug, PartialEq, Eq)] 449 pub struct Job { 450 pub job_id: String, 451 pub status: JobStatus, 452 pub terminal: bool, 453 pub delivery_satisfied: bool, 454 pub event_id: String, 455 pub pubkey: String, 456 pub event_kind: u32, 457 pub target_policy: TargetPolicy, 458 pub delivery_policy: DeliveryPolicy, 459 pub target_count: usize, 460 pub acknowledged_count: usize, 461 pub retryable_count: usize, 462 pub terminal_count: usize, 463 pub requested_at_ms: i64, 464 #[cfg_attr( 465 feature = "serde", 466 serde(default, skip_serializing_if = "Option::is_none") 467 )] 468 pub completed_at_ms: Option<i64>, 469 #[cfg_attr( 470 feature = "serde", 471 serde(default, skip_serializing_if = "Option::is_none") 472 )] 473 pub last_error: Option<String>, 474 #[cfg_attr(feature = "serde", serde(default))] 475 pub targets: Vec<TargetOutcome>, 476 } 477 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 478 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 479 #[derive(Clone, Debug, PartialEq, Eq)] 480 pub struct EventResponse { 481 pub deduplicated: bool, 482 pub job: Job, 483 } 484 485 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 486 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 487 #[derive(Clone, Debug, PartialEq, Eq)] 488 pub struct Capabilities { 489 pub daemon: String, 490 pub api_version: String, 491 pub transports: Vec<String>, 492 pub methods: Vec<String>, 493 pub auth: AuthCapabilities, 494 pub publish: SurfaceCapabilities, 495 } 496 497 impl Capabilities { 498 pub fn v5(max_event_bytes: usize, max_targets_per_request: usize) -> Self { 499 Self { 500 daemon: DAEMON_NAME.to_owned(), 501 api_version: API_VERSION.to_owned(), 502 transports: vec!["jsonrpc_http".to_owned()], 503 methods: vec![ 504 METHOD_CAPABILITIES.to_owned(), 505 METHOD_EVENT.to_owned(), 506 METHOD_JOB_GET.to_owned(), 507 METHOD_JOB_LIST.to_owned(), 508 ], 509 auth: AuthCapabilities { 510 mode: "scoped_bearer_token".to_owned(), 511 }, 512 publish: SurfaceCapabilities { 513 raw_event_json_ingress: true, 514 server_side_user_signing: false, 515 max_event_bytes, 516 max_targets_per_request, 517 delivery_policies: vec![ 518 DeliveryPolicyName::Any, 519 DeliveryPolicyName::Quorum, 520 DeliveryPolicyName::All, 521 DeliveryPolicyName::RequiredTargets, 522 ], 523 target_policy_modes: vec![ 524 TargetPolicyName::ExplicitTargets, 525 TargetPolicyName::Nostr, 526 ], 527 transports: vec![ 528 TransportCapability { 529 transport: "nostr".to_owned(), 530 configured: true, 531 implementation: Implementation::Real, 532 maturity: CapabilityMaturity::Stable, 533 availability: CapabilityAvailability::Available, 534 usable_for_delivery: true, 535 capabilities: OperationCapabilities { 536 deliver: true, 537 fetch: false, 538 discovery: false, 539 gateway_forwarding: false, 540 receipt_observation: false, 541 }, 542 reticulum_behavior: None, 543 message: "Nostr relay publish is available".to_owned(), 544 }, 545 TransportCapability { 546 transport: "reticulum".to_owned(), 547 configured: true, 548 implementation: Implementation::Real, 549 maturity: CapabilityMaturity::Preview, 550 availability: CapabilityAvailability::Unavailable, 551 usable_for_delivery: false, 552 capabilities: OperationCapabilities { 553 deliver: false, 554 fetch: false, 555 discovery: false, 556 gateway_forwarding: false, 557 receipt_observation: false, 558 }, 559 reticulum_behavior: Some(ReticulumBehavior::RejectDeliveryAttempts), 560 message: RETICULUM_UNAVAILABLE_MESSAGE.to_owned(), 561 }, 562 ], 563 }, 564 } 565 } 566 } 567 568 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 569 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 570 #[derive(Clone, Debug, PartialEq, Eq)] 571 pub struct AuthCapabilities { 572 pub mode: String, 573 } 574 575 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 576 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 577 #[derive(Clone, Debug, PartialEq, Eq)] 578 pub struct SurfaceCapabilities { 579 pub raw_event_json_ingress: bool, 580 pub server_side_user_signing: bool, 581 pub max_event_bytes: usize, 582 pub max_targets_per_request: usize, 583 pub delivery_policies: Vec<DeliveryPolicyName>, 584 pub target_policy_modes: Vec<TargetPolicyName>, 585 pub transports: Vec<TransportCapability>, 586 } 587 588 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 589 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 590 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 591 pub enum Implementation { 592 Real, 593 Mock, 594 } 595 596 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 597 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 598 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 599 pub enum CapabilityMaturity { 600 Preview, 601 Stable, 602 } 603 604 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 605 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 606 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 607 pub enum CapabilityAvailability { 608 Available, 609 Degraded, 610 Unavailable, 611 } 612 613 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 614 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 615 #[derive(Clone, Debug, PartialEq, Eq)] 616 pub struct TransportCapability { 617 pub transport: String, 618 pub configured: bool, 619 pub implementation: Implementation, 620 pub maturity: CapabilityMaturity, 621 pub availability: CapabilityAvailability, 622 pub usable_for_delivery: bool, 623 pub capabilities: OperationCapabilities, 624 #[cfg_attr( 625 feature = "serde", 626 serde(default, skip_serializing_if = "Option::is_none") 627 )] 628 pub reticulum_behavior: Option<ReticulumBehavior>, 629 pub message: String, 630 } 631 632 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 633 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 634 #[derive(Clone, Debug, PartialEq, Eq)] 635 pub struct OperationCapabilities { 636 pub deliver: bool, 637 pub fetch: bool, 638 pub discovery: bool, 639 pub gateway_forwarding: bool, 640 pub receipt_observation: bool, 641 } 642 643 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 644 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 645 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 646 pub enum DeliveryPolicyName { 647 Any, 648 Quorum, 649 All, 650 RequiredTargets, 651 } 652 653 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 654 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 655 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 656 pub enum TargetPolicyName { 657 ExplicitTargets, 658 Nostr, 659 } 660 661 /// Canonical serialized target fingerprint. 662 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 663 pub struct TargetFingerprint(String); 664 665 impl TargetFingerprint { 666 /// Parses the exact lowercase 64-hex wire representation. 667 pub fn parse(value: impl Into<String>) -> Result<Self, Error> { 668 let value = value.into(); 669 validate_lower_hex("target_fingerprint", value.as_str(), 64)?; 670 Ok(Self(value)) 671 } 672 673 /// Returns the canonical wire representation. 674 pub fn as_str(&self) -> &str { 675 self.0.as_str() 676 } 677 } 678 679 #[cfg(feature = "serde")] 680 impl serde::Serialize for TargetFingerprint { 681 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 682 where 683 S: serde::Serializer, 684 { 685 serializer.serialize_str(self.as_str()) 686 } 687 } 688 689 #[cfg(feature = "serde")] 690 impl<'de> serde::Deserialize<'de> for TargetFingerprint { 691 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 692 where 693 D: serde::Deserializer<'de>, 694 { 695 let value = <String as serde::Deserialize>::deserialize(deserializer)?; 696 Self::parse(value).map_err(serde::de::Error::custom) 697 } 698 } 699 700 impl Target { 701 /// Creates a passive Nostr target DTO. 702 pub fn nostr(endpoint_uri: impl Into<String>) -> Self { 703 Self { 704 transport_kind: "nostr".to_owned(), 705 endpoint_uri: endpoint_uri.into(), 706 target_scope: None, 707 target_label: None, 708 reticulum_behavior: None, 709 } 710 } 711 712 /// Creates the canonical passive Reticulum target DTO. 713 pub fn reticulum(behavior: ReticulumBehavior) -> Self { 714 Self { 715 transport_kind: "reticulum".to_owned(), 716 endpoint_uri: RETICULUM_ENDPOINT_URI.to_owned(), 717 target_scope: None, 718 target_label: None, 719 reticulum_behavior: Some(behavior), 720 } 721 } 722 723 /// Adds a serialized mesh scope. 724 pub fn with_scope(mut self, target_scope: impl Into<String>) -> Self { 725 self.target_scope = Some(target_scope.into()); 726 self 727 } 728 729 /// Adds a serialized display label. 730 pub fn with_label(mut self, target_label: impl Into<String>) -> Self { 731 self.target_label = Some(target_label.into()); 732 self 733 } 734 735 fn validate_structure(&self, index: usize) -> Result<(), Error> { 736 match self.transport_kind.as_str() { 737 "" => return Err(Error::EmptyTransportKind { index }), 738 "local" | "nostr" | "reticulum" => {} 739 _ if self.transport_kind.trim().is_empty() => { 740 return Err(Error::EmptyTransportKind { index }); 741 } 742 _ => return Err(Error::InvalidTransportKind { index }), 743 } 744 validate_endpoint( 745 self.transport_kind.as_str(), 746 self.endpoint_uri.as_str(), 747 index, 748 )?; 749 validate_target_metadata( 750 self.target_scope.as_deref(), 751 self.target_label.as_deref(), 752 index, 753 )?; 754 if self.transport_kind != "reticulum" && self.reticulum_behavior.is_some() { 755 return Err(Error::InvalidReticulumBehavior { index }); 756 } 757 if self.transport_kind == "reticulum" && self.endpoint_uri != RETICULUM_ENDPOINT_URI { 758 return Err(Error::InvalidReticulumEndpoint { index }); 759 } 760 Ok(()) 761 } 762 763 fn same_wire_identity(&self, outcome: &TargetOutcome) -> bool { 764 self.transport_kind == outcome.transport_kind 765 && self.endpoint_uri == outcome.endpoint_uri 766 && self.target_scope == outcome.target_scope 767 } 768 } 769 770 impl TargetPolicy { 771 /// Creates an explicit-target policy. 772 pub fn explicit_targets(targets: Vec<Target>) -> Self { 773 Self::ExplicitTargets { targets } 774 } 775 776 /// Creates a Nostr relay-source policy. 777 pub fn nostr(source_policy: NostrTargetSourcePolicy, relay_urls: Vec<String>) -> Self { 778 Self::Nostr { 779 source_policy, 780 relay_urls, 781 } 782 } 783 784 /// Returns the number of request-declared targets. 785 pub fn request_target_count(&self) -> usize { 786 match self { 787 Self::ExplicitTargets { targets } => targets.len(), 788 Self::Nostr { relay_urls, .. } => relay_urls.len(), 789 } 790 } 791 792 fn validate_structure(&self, max_targets: usize) -> Result<(), Error> { 793 match self { 794 Self::ExplicitTargets { targets } => { 795 validate_target_limit(targets.len(), max_targets)?; 796 if targets.is_empty() { 797 return Err(Error::EmptyTargetSet); 798 } 799 for (index, target) in targets.iter().enumerate() { 800 target.validate_structure(index)?; 801 if targets[..index].iter().any(|prior| { 802 prior.transport_kind == target.transport_kind 803 && prior.endpoint_uri == target.endpoint_uri 804 && prior.target_scope == target.target_scope 805 }) { 806 return Err(Error::DuplicateTarget { index }); 807 } 808 } 809 } 810 Self::Nostr { relay_urls, .. } => { 811 validate_target_limit(relay_urls.len(), max_targets)?; 812 for (index, endpoint) in relay_urls.iter().enumerate() { 813 validate_endpoint("nostr", endpoint, index)?; 814 if relay_urls[..index].contains(endpoint) { 815 return Err(Error::DuplicateTarget { index }); 816 } 817 } 818 } 819 } 820 Ok(()) 821 } 822 } 823 824 impl DeliveryPolicy { 825 /// Creates a required-target policy after structural validation. 826 pub fn required_targets(targets: Vec<TargetFingerprint>) -> Result<Self, Error> { 827 validate_required_target_fingerprints(targets.as_slice())?; 828 Ok(Self::RequiredTargets { targets }) 829 } 830 831 /// Validates quorum and required-target structure. 832 pub fn validate(&self) -> Result<(), Error> { 833 match self { 834 Self::Quorum { quorum: 0 } => Err(Error::InvalidQuorum), 835 Self::RequiredTargets { targets } => { 836 validate_required_target_fingerprints(targets.as_slice()) 837 } 838 Self::Any | Self::All | Self::Quorum { .. } => Ok(()), 839 } 840 } 841 842 /// Returns the count required for delivery satisfaction. 843 pub fn required_target_count(&self, target_count: usize) -> usize { 844 match self { 845 Self::Any => usize::from(target_count > 0), 846 Self::All => target_count, 847 Self::Quorum { quorum } => *quorum, 848 Self::RequiredTargets { targets } => targets.len(), 849 } 850 } 851 } 852 853 impl EventRequest { 854 /// Performs wire-structural validation without creating native targets. 855 pub fn validate(&self, max_targets: usize) -> Result<(), Error> { 856 if self.raw_event_json.is_empty() { 857 return Err(Error::EmptyRawEventJson); 858 } 859 self.target_policy.validate_structure(max_targets)?; 860 self.delivery_policy.validate()?; 861 if self 862 .idempotency_key 863 .as_ref() 864 .is_some_and(|key| key.trim().is_empty()) 865 { 866 return Err(Error::EmptyIdempotencyKey); 867 } 868 if self.timeout_ms == Some(0) { 869 return Err(Error::InvalidTimeoutMs); 870 } 871 Ok(()) 872 } 873 } 874 875 impl Job { 876 /// Performs transport-neutral structural validation of a job receipt. 877 pub fn validate(&self) -> Result<(), Error> { 878 if self.job_id.trim().is_empty() { 879 return Err(Error::EmptyJobId); 880 } 881 validate_lower_hex("event_id", self.event_id.as_str(), 64)?; 882 validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; 883 self.target_policy.validate_structure(usize::MAX)?; 884 self.delivery_policy.validate()?; 885 if self.terminal != job_status_is_terminal(self.status) { 886 return Err(Error::InvalidJobTerminalState); 887 } 888 if self.delivery_satisfied != (self.status == JobStatus::DeliverySatisfied) { 889 return Err(Error::InvalidJobDeliverySatisfiedState); 890 } 891 let completed = job_status_has_completed_at(self.status); 892 if self.completed_at_ms.is_some() != completed 893 || self 894 .completed_at_ms 895 .is_some_and(|completed_at| completed_at < self.requested_at_ms) 896 { 897 return Err(Error::InvalidJobCompletedAt); 898 } 899 for (index, target) in self.targets.iter().enumerate() { 900 validate_target_outcome(target, index)?; 901 } 902 validate_explicit_outcomes(&self.target_policy, self.targets.as_slice())?; 903 if (!self.targets.is_empty() || completed) && self.target_count != self.targets.len() { 904 return Err(Error::InvalidJobTargetCount { 905 expected: self.targets.len(), 906 actual: self.target_count, 907 }); 908 } 909 910 let acknowledged = self 911 .targets 912 .iter() 913 .filter(|target| target.outcome_kind.counts_toward_accepted_delivery()) 914 .count(); 915 let retryable = self 916 .targets 917 .iter() 918 .filter(|target| target.outcome_kind.is_retryable()) 919 .count(); 920 let terminal = self 921 .targets 922 .iter() 923 .filter(|target| target.outcome_kind.is_terminal_failure()) 924 .count(); 925 if self.acknowledged_count != acknowledged { 926 return Err(Error::InvalidJobAcknowledgedCount { 927 expected: acknowledged, 928 actual: self.acknowledged_count, 929 }); 930 } 931 if self.retryable_count != retryable { 932 return Err(Error::InvalidJobRetryableCount { 933 expected: retryable, 934 actual: self.retryable_count, 935 }); 936 } 937 if self.terminal_count != terminal { 938 return Err(Error::InvalidJobTerminalCount { 939 expected: terminal, 940 actual: self.terminal_count, 941 }); 942 } 943 validate_job_status(self, acknowledged, retryable, terminal) 944 } 945 } 946 947 fn validate_endpoint(kind: &str, endpoint: &str, index: usize) -> Result<(), Error> { 948 if endpoint.trim().is_empty() { 949 return Err(Error::EmptyEndpointUri { index }); 950 } 951 if endpoint != endpoint.trim() 952 || endpoint 953 .bytes() 954 .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) 955 { 956 return Err(Error::InvalidEndpointUri { index }); 957 } 958 if kind == "reticulum" && endpoint != RETICULUM_ENDPOINT_URI { 959 return Err(Error::InvalidReticulumEndpoint { index }); 960 } 961 if kind == "nostr" && !(endpoint.starts_with("wss://") || endpoint.starts_with("ws://")) { 962 return Err(Error::InvalidEndpointUri { index }); 963 } 964 Ok(()) 965 } 966 967 fn validate_target_metadata( 968 scope: Option<&str>, 969 label: Option<&str>, 970 index: usize, 971 ) -> Result<(), Error> { 972 if let Some(scope) = scope { 973 if scope.is_empty() { 974 return Err(Error::EmptyTargetScope { index }); 975 } 976 if scope != scope.trim() 977 || scope 978 .bytes() 979 .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))) 980 { 981 return Err(Error::InvalidTargetScope { index }); 982 } 983 } 984 if let Some(label) = label { 985 if label.trim().is_empty() { 986 return Err(Error::EmptyTargetLabel { index }); 987 } 988 if label != label.trim() || label.chars().any(char::is_control) { 989 return Err(Error::InvalidTargetLabel { index }); 990 } 991 } 992 Ok(()) 993 } 994 995 fn validate_required_target_fingerprints(targets: &[TargetFingerprint]) -> Result<(), Error> { 996 if targets.is_empty() { 997 return Err(Error::EmptyRequiredTargetSet); 998 } 999 let mut seen = BTreeSet::new(); 1000 for (index, target) in targets.iter().enumerate() { 1001 if !seen.insert(target.as_str()) { 1002 return Err(Error::DuplicateRequiredTargetFingerprint { index }); 1003 } 1004 } 1005 Ok(()) 1006 } 1007 1008 fn validate_target_limit(target_count: usize, max_targets: usize) -> Result<(), Error> { 1009 if target_count > max_targets { 1010 Err(Error::TargetLimitExceeded { 1011 max: max_targets, 1012 actual: target_count, 1013 }) 1014 } else { 1015 Ok(()) 1016 } 1017 } 1018 1019 fn validate_lower_hex(field: &'static str, value: &str, expected_len: usize) -> Result<(), Error> { 1020 if value.len() == expected_len 1021 && value 1022 .bytes() 1023 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) 1024 { 1025 Ok(()) 1026 } else { 1027 Err(Error::InvalidHexField { 1028 field, 1029 expected_len, 1030 }) 1031 } 1032 } 1033 1034 fn job_status_is_terminal(status: JobStatus) -> bool { 1035 matches!( 1036 status, 1037 JobStatus::DeliverySatisfied 1038 | JobStatus::DeliveryUnsatisfiedTerminal 1039 | JobStatus::DeliveryDeferred 1040 | JobStatus::DeliveryDeferredUntilImplemented 1041 | JobStatus::Rejected 1042 ) 1043 } 1044 1045 fn job_status_has_completed_at(status: JobStatus) -> bool { 1046 !matches!(status, JobStatus::Accepted | JobStatus::Publishing) 1047 } 1048 1049 fn validate_target_outcome(target: &TargetOutcome, index: usize) -> Result<(), Error> { 1050 Target { 1051 transport_kind: target.transport_kind.clone(), 1052 endpoint_uri: target.endpoint_uri.clone(), 1053 target_scope: target.target_scope.clone(), 1054 target_label: target.target_label.clone(), 1055 reticulum_behavior: None, 1056 } 1057 .validate_structure(index)?; 1058 1059 if target.transport_kind == "reticulum" { 1060 if target.source != TargetSource::Reticulum 1061 || target.attempted 1062 || !target.outcome_kind.is_deferred_until_implemented() 1063 { 1064 return Err(Error::InvalidReticulumOutcome { index }); 1065 } 1066 } else { 1067 if target.source == TargetSource::Reticulum { 1068 return Err(Error::InvalidTargetSource { index }); 1069 } 1070 if target.outcome_kind.is_deferred_until_implemented() { 1071 return Err(Error::InvalidTargetOutcomeKind { index }); 1072 } 1073 } 1074 Ok(()) 1075 } 1076 1077 fn validate_explicit_outcomes( 1078 policy: &TargetPolicy, 1079 outcomes: &[TargetOutcome], 1080 ) -> Result<(), Error> { 1081 let TargetPolicy::ExplicitTargets { targets } = policy else { 1082 return Ok(()); 1083 }; 1084 if outcomes.is_empty() { 1085 return Ok(()); 1086 } 1087 if targets.len() != outcomes.len() { 1088 return Err(Error::InvalidExplicitTargetOutcome { 1089 index: outcomes.len().min(targets.len()), 1090 }); 1091 } 1092 let mut matched = vec![false; targets.len()]; 1093 for (outcome_index, outcome) in outcomes.iter().enumerate() { 1094 let Some(index) = targets.iter().enumerate().find_map(|(index, target)| { 1095 (!matched[index] && target.same_wire_identity(outcome)).then_some(index) 1096 }) else { 1097 return Err(Error::InvalidExplicitTargetOutcome { 1098 index: outcome_index, 1099 }); 1100 }; 1101 matched[index] = true; 1102 } 1103 Ok(()) 1104 } 1105 1106 fn validate_job_status( 1107 job: &Job, 1108 acknowledged: usize, 1109 retryable: usize, 1110 terminal: usize, 1111 ) -> Result<(), Error> { 1112 if matches!(job.status, JobStatus::Accepted | JobStatus::Publishing) { 1113 return Ok(()); 1114 } 1115 if job.status == JobStatus::Rejected { 1116 return (job.target_count == 0 1117 && job.targets.is_empty() 1118 && acknowledged == 0 1119 && retryable == 0 1120 && terminal == 0) 1121 .then_some(()) 1122 .ok_or(Error::InvalidJobStatusState); 1123 } 1124 if job.targets.is_empty() { 1125 return Err(Error::InvalidJobStatusState); 1126 } 1127 if matches!(job.delivery_policy, DeliveryPolicy::RequiredTargets { .. }) { 1128 // Matching fingerprints to native targets is intentionally deferred to 1129 // the transport conversion boundary. All other job invariants remain 1130 // structural and are enforced above. 1131 return Ok(()); 1132 } 1133 1134 let satisfied = acknowledged >= job.delivery_policy.required_target_count(job.target_count); 1135 let deferred = job 1136 .targets 1137 .iter() 1138 .any(|target| target.outcome_kind == OutcomeKind::DeferredUntilImplemented); 1139 let matches = if satisfied { 1140 job.status == JobStatus::DeliverySatisfied 1141 } else if retryable > 0 { 1142 job.status == JobStatus::DeliveryUnsatisfiedRetryable 1143 } else if terminal > 0 { 1144 job.status == JobStatus::DeliveryUnsatisfiedTerminal 1145 } else if deferred { 1146 matches!( 1147 job.status, 1148 JobStatus::DeliveryDeferred | JobStatus::DeliveryDeferredUntilImplemented 1149 ) 1150 } else { 1151 false 1152 }; 1153 matches.then_some(()).ok_or(Error::InvalidJobStatusState) 1154 } 1155 1156 /// Builds the schema registry for the V5 daemon transport-publish contract. 1157 pub fn schema_registry() -> Result<Registry, crate::schema::Error> { 1158 Registry::try_new([SchemaDescriptor::try_new( 1159 API_VERSION, 1160 ModuleVersion::RadrootsdTransportPublishV5, 1161 )?]) 1162 } 1163 1164 #[cfg(test)] 1165 mod tests { 1166 use super::*; 1167 1168 fn request() -> EventRequest { 1169 EventRequest { 1170 raw_event_json: "{\"id\":\"event\"}".to_owned(), 1171 target_policy: TargetPolicy::explicit_targets(vec![Target::nostr( 1172 "wss://relay.example.com", 1173 )]), 1174 delivery_policy: DeliveryPolicy::Any, 1175 idempotency_key: Some("idem-1".to_owned()), 1176 timeout_ms: Some(5_000), 1177 } 1178 } 1179 1180 fn accepted_job() -> Job { 1181 Job { 1182 job_id: "job-1".to_owned(), 1183 status: JobStatus::DeliverySatisfied, 1184 terminal: true, 1185 delivery_satisfied: true, 1186 event_id: "0".repeat(64), 1187 pubkey: "1".repeat(64), 1188 event_kind: 30_402, 1189 target_policy: TargetPolicy::explicit_targets(vec![Target::nostr( 1190 "wss://relay.example.com", 1191 )]), 1192 delivery_policy: DeliveryPolicy::Any, 1193 target_count: 1, 1194 acknowledged_count: 1, 1195 retryable_count: 0, 1196 terminal_count: 0, 1197 requested_at_ms: 1, 1198 completed_at_ms: Some(2), 1199 last_error: None, 1200 targets: vec![TargetOutcome { 1201 transport_kind: "nostr".to_owned(), 1202 endpoint_uri: "wss://relay.example.com".to_owned(), 1203 target_scope: None, 1204 target_label: None, 1205 source: TargetSource::Request, 1206 attempted: true, 1207 outcome_kind: OutcomeKind::Accepted, 1208 message: None, 1209 latency_ms: Some(7), 1210 }], 1211 } 1212 } 1213 1214 fn outcome(kind: OutcomeKind) -> TargetOutcome { 1215 TargetOutcome { 1216 transport_kind: "nostr".to_owned(), 1217 endpoint_uri: "wss://relay.example.com".to_owned(), 1218 target_scope: None, 1219 target_label: None, 1220 source: TargetSource::Request, 1221 attempted: true, 1222 outcome_kind: kind, 1223 message: None, 1224 latency_ms: None, 1225 } 1226 } 1227 1228 fn completed_job(status: JobStatus, kind: OutcomeKind) -> Job { 1229 let mut job = accepted_job(); 1230 job.status = status; 1231 job.terminal = job_status_is_terminal(status); 1232 job.delivery_satisfied = status == JobStatus::DeliverySatisfied; 1233 job.targets = vec![outcome(kind)]; 1234 job.acknowledged_count = usize::from(kind.counts_toward_accepted_delivery()); 1235 job.retryable_count = usize::from(kind.is_retryable()); 1236 job.terminal_count = usize::from(kind.is_terminal_failure()); 1237 job 1238 } 1239 1240 #[test] 1241 fn request_job_and_schema_registry_validate() { 1242 request().validate(1).expect("request"); 1243 accepted_job().validate().expect("job"); 1244 let registry = schema_registry().expect("schema registry"); 1245 assert_eq!(registry.len(), 1); 1246 assert_eq!( 1247 registry.descriptors()[0].module(), 1248 ModuleVersion::RadrootsdTransportPublishV5 1249 ); 1250 } 1251 1252 #[test] 1253 fn structural_validation_rejects_invalid_fields() { 1254 let mut invalid_timeout = request(); 1255 invalid_timeout.timeout_ms = Some(0); 1256 assert_eq!(invalid_timeout.validate(1), Err(Error::InvalidTimeoutMs)); 1257 1258 let mut invalid_endpoint = request(); 1259 invalid_endpoint.target_policy = 1260 TargetPolicy::explicit_targets(vec![Target::nostr("WSS://relay.example.com")]); 1261 assert_eq!( 1262 invalid_endpoint.validate(1), 1263 Err(Error::InvalidEndpointUri { index: 0 }) 1264 ); 1265 1266 let mut job = accepted_job(); 1267 job.event_id = "ABC".repeat(21); 1268 assert_eq!( 1269 job.validate(), 1270 Err(Error::InvalidHexField { 1271 field: "event_id", 1272 expected_len: 64, 1273 }) 1274 ); 1275 } 1276 1277 #[test] 1278 fn errors_have_stable_human_readable_messages() { 1279 let errors = [ 1280 Error::InvalidHexField { 1281 field: "id", 1282 expected_len: 64, 1283 }, 1284 Error::EmptyRawEventJson, 1285 Error::EmptyTag { index: 1 }, 1286 Error::EmptyIdempotencyKey, 1287 Error::EmptyTransportKind { index: 2 }, 1288 Error::InvalidTransportKind { index: 3 }, 1289 Error::EmptyEndpointUri { index: 4 }, 1290 Error::InvalidEndpointUri { index: 5 }, 1291 Error::EmptyTargetScope { index: 6 }, 1292 Error::InvalidTargetScope { index: 7 }, 1293 Error::EmptyTargetLabel { index: 8 }, 1294 Error::InvalidTargetLabel { index: 9 }, 1295 Error::InvalidReticulumBehavior { index: 10 }, 1296 Error::InvalidTimeoutMs, 1297 Error::InvalidReticulumEndpoint { index: 11 }, 1298 Error::DuplicateTarget { index: 12 }, 1299 Error::TargetLimitExceeded { max: 1, actual: 2 }, 1300 Error::EmptyTargetSet, 1301 Error::InvalidQuorum, 1302 Error::EmptyRequiredTargetSet, 1303 Error::DuplicateRequiredTargetFingerprint { index: 13 }, 1304 Error::RequiredTargetNotInTargetSet { index: 14 }, 1305 Error::EmptyPrincipalId, 1306 Error::EmptyJobId, 1307 Error::InvalidJobTargetCount { 1308 expected: 1, 1309 actual: 2, 1310 }, 1311 Error::InvalidJobAcknowledgedCount { 1312 expected: 1, 1313 actual: 2, 1314 }, 1315 Error::InvalidJobRetryableCount { 1316 expected: 1, 1317 actual: 2, 1318 }, 1319 Error::InvalidJobTerminalCount { 1320 expected: 1, 1321 actual: 2, 1322 }, 1323 Error::InvalidJobTerminalState, 1324 Error::InvalidJobDeliverySatisfiedState, 1325 Error::InvalidJobCompletedAt, 1326 Error::InvalidJobStatusState, 1327 Error::InvalidExplicitTargetOutcome { index: 15 }, 1328 Error::InvalidTargetOutcomeKind { index: 16 }, 1329 Error::InvalidTargetSource { index: 17 }, 1330 Error::InvalidReticulumOutcome { index: 18 }, 1331 ]; 1332 for error in errors { 1333 assert!(!error.to_string().is_empty()); 1334 } 1335 } 1336 1337 #[test] 1338 fn target_and_request_validation_cover_every_structural_rule() { 1339 let valid = Target::nostr("ws://relay.example.com") 1340 .with_scope("farm.eu-1") 1341 .with_label("Farm relay"); 1342 assert_eq!(valid.transport_kind, "nostr"); 1343 assert_eq!(valid.target_scope.as_deref(), Some("farm.eu-1")); 1344 assert_eq!(valid.target_label.as_deref(), Some("Farm relay")); 1345 assert!(valid.validate_structure(0).is_ok()); 1346 assert!( 1347 Target::reticulum(ReticulumBehavior::DeferDeliveryPlans) 1348 .validate_structure(0) 1349 .is_ok() 1350 ); 1351 1352 let invalid = [ 1353 ( 1354 Target { 1355 transport_kind: String::new(), 1356 ..valid.clone() 1357 }, 1358 Error::EmptyTransportKind { index: 0 }, 1359 ), 1360 ( 1361 Target { 1362 transport_kind: " \t".to_owned(), 1363 ..valid.clone() 1364 }, 1365 Error::EmptyTransportKind { index: 0 }, 1366 ), 1367 ( 1368 Target { 1369 transport_kind: "NOSTR".to_owned(), 1370 ..valid.clone() 1371 }, 1372 Error::InvalidTransportKind { index: 0 }, 1373 ), 1374 ( 1375 Target { 1376 endpoint_uri: String::new(), 1377 ..valid.clone() 1378 }, 1379 Error::EmptyEndpointUri { index: 0 }, 1380 ), 1381 ( 1382 Target { 1383 endpoint_uri: " wss://relay.example.com".to_owned(), 1384 ..valid.clone() 1385 }, 1386 Error::InvalidEndpointUri { index: 0 }, 1387 ), 1388 ( 1389 Target { 1390 endpoint_uri: "https://relay.example.com".to_owned(), 1391 ..valid.clone() 1392 }, 1393 Error::InvalidEndpointUri { index: 0 }, 1394 ), 1395 ( 1396 Target { 1397 target_scope: Some(String::new()), 1398 ..valid.clone() 1399 }, 1400 Error::EmptyTargetScope { index: 0 }, 1401 ), 1402 ( 1403 Target { 1404 target_scope: Some("bad scope".to_owned()), 1405 ..valid.clone() 1406 }, 1407 Error::InvalidTargetScope { index: 0 }, 1408 ), 1409 ( 1410 Target { 1411 target_scope: Some(" scope".to_owned()), 1412 ..valid.clone() 1413 }, 1414 Error::InvalidTargetScope { index: 0 }, 1415 ), 1416 ( 1417 Target { 1418 target_label: Some(" \t".to_owned()), 1419 ..valid.clone() 1420 }, 1421 Error::EmptyTargetLabel { index: 0 }, 1422 ), 1423 ( 1424 Target { 1425 target_label: Some(" label".to_owned()), 1426 ..valid.clone() 1427 }, 1428 Error::InvalidTargetLabel { index: 0 }, 1429 ), 1430 ( 1431 Target { 1432 target_label: Some("bad\nlabel".to_owned()), 1433 ..valid.clone() 1434 }, 1435 Error::InvalidTargetLabel { index: 0 }, 1436 ), 1437 ( 1438 Target { 1439 reticulum_behavior: Some(ReticulumBehavior::RejectDeliveryAttempts), 1440 ..valid.clone() 1441 }, 1442 Error::InvalidReticulumBehavior { index: 0 }, 1443 ), 1444 ( 1445 Target { 1446 transport_kind: "reticulum".to_owned(), 1447 endpoint_uri: "reticulum:other".to_owned(), 1448 target_scope: None, 1449 target_label: None, 1450 reticulum_behavior: None, 1451 }, 1452 Error::InvalidReticulumEndpoint { index: 0 }, 1453 ), 1454 ]; 1455 for (target, error) in invalid { 1456 assert_eq!(target.validate_structure(0), Err(error)); 1457 } 1458 1459 let mut empty = request(); 1460 empty.raw_event_json.clear(); 1461 assert_eq!(empty.validate(1), Err(Error::EmptyRawEventJson)); 1462 let mut no_targets = request(); 1463 no_targets.target_policy = TargetPolicy::explicit_targets(vec![]); 1464 assert_eq!(no_targets.validate(1), Err(Error::EmptyTargetSet)); 1465 let mut too_many = request(); 1466 too_many.target_policy = TargetPolicy::explicit_targets(vec![ 1467 valid.clone(), 1468 Target::nostr("wss://second.example.com"), 1469 ]); 1470 assert_eq!( 1471 too_many.validate(1), 1472 Err(Error::TargetLimitExceeded { max: 1, actual: 2 }) 1473 ); 1474 let mut duplicate = request(); 1475 duplicate.target_policy = TargetPolicy::explicit_targets(vec![valid.clone(), valid]); 1476 assert_eq!( 1477 duplicate.validate(2), 1478 Err(Error::DuplicateTarget { index: 1 }) 1479 ); 1480 let mut nostr = request(); 1481 nostr.target_policy = TargetPolicy::nostr( 1482 NostrTargetSourcePolicy::DaemonDefaultOnly, 1483 vec!["wss://a.example".to_owned(), "wss://a.example".to_owned()], 1484 ); 1485 assert_eq!(nostr.target_policy.request_target_count(), 2); 1486 assert_eq!(nostr.validate(2), Err(Error::DuplicateTarget { index: 1 })); 1487 nostr.target_policy = TargetPolicy::nostr( 1488 NostrTargetSourcePolicy::ExplicitOnly, 1489 vec!["https://bad.example".to_owned()], 1490 ); 1491 assert_eq!( 1492 nostr.validate(1), 1493 Err(Error::InvalidEndpointUri { index: 0 }) 1494 ); 1495 let mut blank_key = request(); 1496 blank_key.idempotency_key = Some(" \t".to_owned()); 1497 assert_eq!(blank_key.validate(1), Err(Error::EmptyIdempotencyKey)); 1498 } 1499 1500 #[test] 1501 fn delivery_policy_and_outcome_classifications_are_exhaustive() { 1502 let fingerprint = TargetFingerprint::parse("a".repeat(64)).expect("fingerprint"); 1503 assert_eq!(fingerprint.as_str(), "a".repeat(64)); 1504 assert!(TargetFingerprint::parse("A".repeat(64)).is_err()); 1505 assert_eq!( 1506 DeliveryPolicy::required_targets(vec![]), 1507 Err(Error::EmptyRequiredTargetSet) 1508 ); 1509 assert_eq!( 1510 DeliveryPolicy::required_targets(vec![fingerprint.clone(), fingerprint.clone()]), 1511 Err(Error::DuplicateRequiredTargetFingerprint { index: 1 }) 1512 ); 1513 let required = DeliveryPolicy::required_targets(vec![fingerprint]).expect("required"); 1514 assert_eq!(required.required_target_count(9), 1); 1515 assert!(required.validate().is_ok()); 1516 assert_eq!( 1517 DeliveryPolicy::Quorum { quorum: 0 }.validate(), 1518 Err(Error::InvalidQuorum) 1519 ); 1520 assert!(DeliveryPolicy::Any.validate().is_ok()); 1521 assert!(DeliveryPolicy::All.validate().is_ok()); 1522 assert!(DeliveryPolicy::Quorum { quorum: 2 }.validate().is_ok()); 1523 assert_eq!(DeliveryPolicy::Any.required_target_count(0), 0); 1524 assert_eq!(DeliveryPolicy::Any.required_target_count(2), 1); 1525 assert_eq!(DeliveryPolicy::All.required_target_count(2), 2); 1526 assert_eq!( 1527 DeliveryPolicy::Quorum { quorum: 2 }.required_target_count(9), 1528 2 1529 ); 1530 1531 for kind in [ 1532 OutcomeKind::Accepted, 1533 OutcomeKind::DuplicateAccepted, 1534 OutcomeKind::SkippedAlreadyAccepted, 1535 ] { 1536 assert!(kind.counts_toward_accepted_delivery()); 1537 assert!(!kind.is_retryable()); 1538 assert!(!kind.is_terminal_failure()); 1539 } 1540 for kind in [ 1541 OutcomeKind::RateLimited, 1542 OutcomeKind::PowRequired, 1543 OutcomeKind::AuthRequired, 1544 OutcomeKind::Error, 1545 OutcomeKind::Timeout, 1546 OutcomeKind::ConnectionFailed, 1547 OutcomeKind::Unknown, 1548 ] { 1549 assert!(kind.is_retryable()); 1550 assert!(!kind.counts_toward_accepted_delivery()); 1551 } 1552 for kind in [ 1553 OutcomeKind::Blocked, 1554 OutcomeKind::Invalid, 1555 OutcomeKind::Restricted, 1556 OutcomeKind::Muted, 1557 OutcomeKind::Unsupported, 1558 OutcomeKind::PaymentRequired, 1559 OutcomeKind::TargetRejected, 1560 ] { 1561 assert!(kind.is_terminal_failure()); 1562 assert!(!kind.is_retryable()); 1563 } 1564 assert!(OutcomeKind::DeferredUntilImplemented.is_deferred_until_implemented()); 1565 } 1566 1567 #[test] 1568 fn job_validation_covers_counts_lifecycle_and_transport_rules() { 1569 for status in [JobStatus::Accepted, JobStatus::Publishing] { 1570 let mut job = accepted_job(); 1571 job.status = status; 1572 job.terminal = false; 1573 job.delivery_satisfied = false; 1574 job.completed_at_ms = None; 1575 assert!(job.validate().is_ok()); 1576 } 1577 for (status, kind) in [ 1578 (JobStatus::DeliverySatisfied, OutcomeKind::Accepted), 1579 ( 1580 JobStatus::DeliveryUnsatisfiedRetryable, 1581 OutcomeKind::Timeout, 1582 ), 1583 (JobStatus::DeliveryUnsatisfiedTerminal, OutcomeKind::Blocked), 1584 ] { 1585 assert!(completed_job(status, kind).validate().is_ok()); 1586 } 1587 let mut deferred = completed_job( 1588 JobStatus::DeliveryDeferred, 1589 OutcomeKind::DeferredUntilImplemented, 1590 ); 1591 deferred.target_policy = TargetPolicy::explicit_targets(vec![Target::reticulum( 1592 ReticulumBehavior::DeferDeliveryPlans, 1593 )]); 1594 deferred.targets[0] = TargetOutcome { 1595 transport_kind: "reticulum".to_owned(), 1596 endpoint_uri: RETICULUM_ENDPOINT_URI.to_owned(), 1597 source: TargetSource::Reticulum, 1598 attempted: false, 1599 outcome_kind: OutcomeKind::DeferredUntilImplemented, 1600 ..outcome(OutcomeKind::DeferredUntilImplemented) 1601 }; 1602 assert!(deferred.validate().is_ok()); 1603 deferred.status = JobStatus::DeliveryDeferredUntilImplemented; 1604 assert!(deferred.validate().is_ok()); 1605 1606 let mut rejected = accepted_job(); 1607 rejected.status = JobStatus::Rejected; 1608 rejected.terminal = true; 1609 rejected.delivery_satisfied = false; 1610 rejected.target_policy = 1611 TargetPolicy::nostr(NostrTargetSourcePolicy::DaemonDefaultOnly, vec![]); 1612 rejected.target_count = 0; 1613 rejected.acknowledged_count = 0; 1614 rejected.targets.clear(); 1615 assert!(rejected.validate().is_ok()); 1616 1617 let mut cases = Vec::new(); 1618 let mut job = accepted_job(); 1619 job.job_id = " ".to_owned(); 1620 cases.push((job, Error::EmptyJobId)); 1621 let mut job = accepted_job(); 1622 job.pubkey = "g".repeat(64); 1623 cases.push(( 1624 job, 1625 Error::InvalidHexField { 1626 field: "pubkey", 1627 expected_len: 64, 1628 }, 1629 )); 1630 let mut job = accepted_job(); 1631 job.terminal = false; 1632 cases.push((job, Error::InvalidJobTerminalState)); 1633 let mut job = accepted_job(); 1634 job.delivery_satisfied = false; 1635 cases.push((job, Error::InvalidJobDeliverySatisfiedState)); 1636 let mut job = accepted_job(); 1637 job.completed_at_ms = None; 1638 cases.push((job, Error::InvalidJobCompletedAt)); 1639 let mut job = accepted_job(); 1640 job.completed_at_ms = Some(0); 1641 cases.push((job, Error::InvalidJobCompletedAt)); 1642 let mut job = accepted_job(); 1643 job.target_count = 2; 1644 cases.push(( 1645 job, 1646 Error::InvalidJobTargetCount { 1647 expected: 1, 1648 actual: 2, 1649 }, 1650 )); 1651 let mut job = accepted_job(); 1652 job.acknowledged_count = 0; 1653 cases.push(( 1654 job, 1655 Error::InvalidJobAcknowledgedCount { 1656 expected: 1, 1657 actual: 0, 1658 }, 1659 )); 1660 let mut job = completed_job( 1661 JobStatus::DeliveryUnsatisfiedRetryable, 1662 OutcomeKind::Timeout, 1663 ); 1664 job.retryable_count = 0; 1665 cases.push(( 1666 job, 1667 Error::InvalidJobRetryableCount { 1668 expected: 1, 1669 actual: 0, 1670 }, 1671 )); 1672 let mut job = completed_job(JobStatus::DeliveryUnsatisfiedTerminal, OutcomeKind::Blocked); 1673 job.terminal_count = 0; 1674 cases.push(( 1675 job, 1676 Error::InvalidJobTerminalCount { 1677 expected: 1, 1678 actual: 0, 1679 }, 1680 )); 1681 for (job, error) in cases { 1682 assert_eq!(job.validate(), Err(error)); 1683 } 1684 1685 let mut invalid = accepted_job(); 1686 invalid.targets[0].source = TargetSource::Reticulum; 1687 assert_eq!( 1688 invalid.validate(), 1689 Err(Error::InvalidTargetSource { index: 0 }) 1690 ); 1691 let mut invalid = accepted_job(); 1692 invalid.targets[0].outcome_kind = OutcomeKind::DeferredUntilImplemented; 1693 assert_eq!( 1694 invalid.validate(), 1695 Err(Error::InvalidTargetOutcomeKind { index: 0 }) 1696 ); 1697 let mut invalid = deferred.clone(); 1698 invalid.targets[0].attempted = true; 1699 assert_eq!( 1700 invalid.validate(), 1701 Err(Error::InvalidReticulumOutcome { index: 0 }) 1702 ); 1703 let mut invalid = accepted_job(); 1704 invalid.targets[0].endpoint_uri = "wss://other.example".to_owned(); 1705 assert_eq!( 1706 invalid.validate(), 1707 Err(Error::InvalidExplicitTargetOutcome { index: 0 }) 1708 ); 1709 let mut invalid = accepted_job(); 1710 invalid.targets.push(outcome(OutcomeKind::Accepted)); 1711 assert_eq!( 1712 invalid.validate(), 1713 Err(Error::InvalidExplicitTargetOutcome { index: 1 }) 1714 ); 1715 let mut invalid = completed_job(JobStatus::DeliverySatisfied, OutcomeKind::Blocked); 1716 invalid.delivery_satisfied = true; 1717 assert_eq!(invalid.validate(), Err(Error::InvalidJobStatusState)); 1718 } 1719 1720 #[cfg(feature = "serde")] 1721 #[test] 1722 fn json_vectors_preserve_v5_names_and_unknown_fields_fail_closed() { 1723 let encoded = serde_json::to_value(request()).expect("request JSON"); 1724 assert_eq!(encoded["target_policy"]["kind"], "explicit_targets"); 1725 assert_eq!(encoded["delivery_policy"]["mode"], "any"); 1726 let mut object = encoded.as_object().expect("request object").clone(); 1727 object.insert("unknown".to_owned(), serde_json::json!(true)); 1728 assert!(serde_json::from_value::<EventRequest>(object.into()).is_err()); 1729 1730 assert_eq!( 1731 serde_json::to_string(&ReticulumBehavior::RejectDeliveryAttempts) 1732 .expect("behavior JSON"), 1733 "\"reject_delivery_attempts\"" 1734 ); 1735 let capabilities = Capabilities::v5(1_024, 10); 1736 assert_eq!(capabilities.api_version, API_VERSION); 1737 assert_eq!( 1738 capabilities.publish.transports[1].message, 1739 RETICULUM_UNAVAILABLE_MESSAGE 1740 ); 1741 } 1742 }