publication.rs (20606B)
1 //! Explicit publication authority and immutable target-set identity. 2 3 use core::fmt; 4 use std::error::Error; 5 6 use serde_json::Value; 7 use sha2::{Digest, Sha256}; 8 9 use crate::{RhiConfigDocumentV1, state_metadata::normalized_config_digest}; 10 11 /// Exact version of the RHI publication-authority contract. 12 pub const RHI_PUBLICATION_CONTRACT_VERSION: u32 = 1; 13 14 /// Absolute number of publication targets admitted by the v1 contract. 15 pub const RHI_PUBLICATION_MAX_TARGETS: usize = 32; 16 17 /// Absolute number of durable publication attempts admitted per target. 18 pub const RHI_PUBLICATION_MAX_ATTEMPTS: u16 = 100; 19 20 const AUTHORITY_DOMAIN: &[u8] = b"radroots.rhi.publication_authority.v1\0"; 21 const TARGET_SET_DOMAIN: &[u8] = b"radroots.rhi.publication_target_set.v1\0"; 22 23 /// Closed publication authority selected by the complete validated configuration. 24 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] 25 pub enum RhiPublicationMode { 26 Required, 27 Disabled, 28 } 29 30 impl RhiPublicationMode { 31 /// Returns the exact machine-contract spelling. 32 #[must_use] 33 pub const fn code(self) -> &'static str { 34 match self { 35 Self::Required => "required", 36 Self::Disabled => "disabled", 37 } 38 } 39 } 40 41 /// Immutable retry authority copied from one validated required-publication config. 42 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] 43 pub struct RhiPublicationRetryPolicy { 44 maximum_attempts: u16, 45 initial_backoff_milliseconds: u64, 46 maximum_backoff_milliseconds: u64, 47 attempt_deadline_milliseconds: u64, 48 } 49 50 impl RhiPublicationRetryPolicy { 51 /// Returns the total allowed attempts for each target. 52 #[must_use] 53 pub const fn maximum_attempts(self) -> u16 { 54 self.maximum_attempts 55 } 56 57 /// Returns the configured initial retry bound in whole milliseconds. 58 #[must_use] 59 pub const fn initial_backoff_milliseconds(self) -> u64 { 60 self.initial_backoff_milliseconds 61 } 62 63 /// Returns the configured maximum retry bound in whole milliseconds. 64 #[must_use] 65 pub const fn maximum_backoff_milliseconds(self) -> u64 { 66 self.maximum_backoff_milliseconds 67 } 68 69 /// Returns the absolute per-attempt duration bound in whole milliseconds. 70 #[must_use] 71 pub const fn attempt_deadline_milliseconds(self) -> u64 { 72 self.attempt_deadline_milliseconds 73 } 74 } 75 76 /// One immutable publication target derived from the configured relay inventory. 77 #[derive(Clone, PartialEq, Eq, Hash)] 78 pub struct RhiPublicationTarget { 79 ordinal: u8, 80 relay_id: Box<str>, 81 required: bool, 82 } 83 84 impl RhiPublicationTarget { 85 /// Returns the stable zero-based position from the configured target inventory. 86 #[must_use] 87 pub const fn ordinal(&self) -> u8 { 88 self.ordinal 89 } 90 91 /// Returns the validated stable relay identifier. 92 #[must_use] 93 pub fn relay_id(&self) -> &str { 94 &self.relay_id 95 } 96 97 /// Returns whether this relay is required by the governed relay authority. 98 #[must_use] 99 pub const fn required(&self) -> bool { 100 self.required 101 } 102 } 103 104 impl fmt::Debug for RhiPublicationTarget { 105 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 106 formatter 107 .debug_struct("RhiPublicationTarget") 108 .field("ordinal", &self.ordinal) 109 .field("relay_id", &"[redacted]") 110 .field("required", &self.required) 111 .finish() 112 } 113 } 114 115 /// Sealed immutable publication authority derived from one validated config. 116 /// 117 /// Disabled authority contains no target or retry state. Required authority 118 /// preserves the complete configured target order and binds every target's 119 /// requiredness, the retry policy, and queue bound into one domain-separated 120 /// digest. Construction performs no I/O. 121 /// 122 /// ```compile_fail 123 /// use rhi::RhiPublicationAuthority; 124 /// 125 /// let _forged = RhiPublicationAuthority { mode: todo!() }; 126 /// ``` 127 #[derive(Clone)] 128 pub struct RhiPublicationAuthority { 129 configuration_sha256: [u8; 32], 130 mode: RhiPublicationMode, 131 targets: Box<[RhiPublicationTarget]>, 132 retry: Option<RhiPublicationRetryPolicy>, 133 queue_capacity: u32, 134 target_set_sha256: [u8; 32], 135 authority_sha256: [u8; 32], 136 } 137 138 impl PartialEq for RhiPublicationAuthority { 139 fn eq(&self, other: &Self) -> bool { 140 self.mode == other.mode 141 && self.targets == other.targets 142 && self.retry == other.retry 143 && self.queue_capacity == other.queue_capacity 144 && self.target_set_sha256 == other.target_set_sha256 145 && self.authority_sha256 == other.authority_sha256 146 } 147 } 148 149 impl Eq for RhiPublicationAuthority {} 150 151 impl RhiPublicationAuthority { 152 /// Derives the only publication authority from one complete admitted config. 153 pub fn from_config(config: &RhiConfigDocumentV1) -> Result<Self, RhiPublicationError> { 154 derive_authority(config.normalized(), config.profile()) 155 } 156 157 /// Returns the explicit configured publication mode. 158 #[must_use] 159 pub const fn mode(&self) -> RhiPublicationMode { 160 self.mode 161 } 162 163 /// Returns the immutable configured target inventory. 164 #[must_use] 165 pub fn targets(&self) -> &[RhiPublicationTarget] { 166 &self.targets 167 } 168 169 /// Returns retry authority only when publication is required. 170 #[must_use] 171 pub const fn retry_policy(&self) -> Option<RhiPublicationRetryPolicy> { 172 self.retry 173 } 174 175 /// Returns the configured durable publication queue bound. 176 #[must_use] 177 pub const fn queue_capacity(&self) -> u32 { 178 self.queue_capacity 179 } 180 181 /// Returns the domain-separated immutable target-set identity. 182 #[must_use] 183 pub const fn target_set_sha256(&self) -> &[u8; 32] { 184 &self.target_set_sha256 185 } 186 187 /// Returns the domain-separated identity of the complete publication authority. 188 #[must_use] 189 pub const fn authority_sha256(&self) -> &[u8; 32] { 190 &self.authority_sha256 191 } 192 193 pub(crate) const fn configuration_sha256(&self) -> &[u8; 32] { 194 &self.configuration_sha256 195 } 196 } 197 198 impl fmt::Debug for RhiPublicationAuthority { 199 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 200 formatter 201 .debug_struct("RhiPublicationAuthority") 202 .field("mode", &self.mode) 203 .field("target_count", &self.targets.len()) 204 .field("queue_capacity", &self.queue_capacity) 205 .finish_non_exhaustive() 206 } 207 } 208 209 /// Stable source-free publication-authority construction failure. 210 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 211 pub enum RhiPublicationErrorKind { 212 InvalidConfiguration, 213 TargetInventory, 214 } 215 216 impl RhiPublicationErrorKind { 217 /// Returns the stable machine-readable failure code. 218 #[must_use] 219 pub const fn code(self) -> &'static str { 220 match self { 221 Self::InvalidConfiguration => "publication_configuration_invalid", 222 Self::TargetInventory => "publication_target_inventory_invalid", 223 } 224 } 225 } 226 227 /// Redacted source-free publication-authority failure. 228 #[derive(Clone, Copy, PartialEq, Eq)] 229 pub struct RhiPublicationError { 230 kind: RhiPublicationErrorKind, 231 } 232 233 impl RhiPublicationError { 234 /// Returns the stable failure class. 235 #[must_use] 236 pub const fn kind(self) -> RhiPublicationErrorKind { 237 self.kind 238 } 239 240 /// Returns the stable machine-readable failure code. 241 #[must_use] 242 pub const fn code(self) -> &'static str { 243 self.kind.code() 244 } 245 } 246 247 impl fmt::Display for RhiPublicationError { 248 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 249 formatter.write_str(match self.kind { 250 RhiPublicationErrorKind::InvalidConfiguration => { 251 "RHI publication configuration is invalid" 252 } 253 RhiPublicationErrorKind::TargetInventory => { 254 "RHI publication target inventory is invalid" 255 } 256 }) 257 } 258 } 259 260 impl fmt::Debug for RhiPublicationError { 261 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 262 formatter 263 .debug_struct("RhiPublicationError") 264 .field("kind", &self.kind) 265 .finish() 266 } 267 } 268 269 impl Error for RhiPublicationError {} 270 271 fn derive_authority( 272 document: &Value, 273 profile: crate::RhiConfigProfile, 274 ) -> Result<RhiPublicationAuthority, RhiPublicationError> { 275 let configuration_sha256 = *normalized_config_digest(profile, document) 276 .map_err(|_| failure(RhiPublicationErrorKind::InvalidConfiguration))? 277 .as_bytes(); 278 let mode = match string(document, "/publication/mode")? { 279 "required" => RhiPublicationMode::Required, 280 "disabled" => RhiPublicationMode::Disabled, 281 _ => return Err(failure(RhiPublicationErrorKind::InvalidConfiguration)), 282 }; 283 let queue_capacity = 284 integer(document, "/resource_limits/queues/publication").and_then(|value| { 285 u32::try_from(value).map_err(|_| failure(RhiPublicationErrorKind::InvalidConfiguration)) 286 })?; 287 if queue_capacity == 0 || queue_capacity > 65_536 { 288 return Err(failure(RhiPublicationErrorKind::InvalidConfiguration)); 289 } 290 291 let (targets, retry) = match mode { 292 RhiPublicationMode::Disabled => { 293 if document.pointer("/publication/target_relay_ids").is_some() 294 || document.pointer("/publication/retry").is_some() 295 { 296 return Err(failure(RhiPublicationErrorKind::InvalidConfiguration)); 297 } 298 (Vec::new(), None) 299 } 300 RhiPublicationMode::Required => { 301 let target_ids = document 302 .pointer("/publication/target_relay_ids") 303 .and_then(Value::as_array) 304 .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?; 305 if target_ids.is_empty() || target_ids.len() > RHI_PUBLICATION_MAX_TARGETS { 306 return Err(failure(RhiPublicationErrorKind::TargetInventory)); 307 } 308 let relays = document 309 .pointer("/relays") 310 .and_then(Value::as_array) 311 .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?; 312 let mut targets = Vec::with_capacity(target_ids.len()); 313 for (ordinal, target_id) in target_ids.iter().enumerate() { 314 let relay_id = target_id 315 .as_str() 316 .filter(|value| valid_relay_id(value)) 317 .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?; 318 if targets 319 .iter() 320 .any(|target: &RhiPublicationTarget| target.relay_id() == relay_id) 321 { 322 return Err(failure(RhiPublicationErrorKind::TargetInventory)); 323 } 324 let relay = relays 325 .iter() 326 .find(|relay| relay.pointer("/id").and_then(Value::as_str) == Some(relay_id)) 327 .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?; 328 if relay.pointer("/write").and_then(Value::as_bool) != Some(true) { 329 return Err(failure(RhiPublicationErrorKind::TargetInventory)); 330 } 331 targets.push(RhiPublicationTarget { 332 ordinal: u8::try_from(ordinal) 333 .map_err(|_| failure(RhiPublicationErrorKind::TargetInventory))?, 334 relay_id: relay_id.into(), 335 required: relay 336 .pointer("/required") 337 .and_then(Value::as_bool) 338 .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?, 339 }); 340 } 341 let maximum_attempts = 342 integer(document, "/publication/retry/max_attempts").and_then(|value| { 343 u16::try_from(value) 344 .map_err(|_| failure(RhiPublicationErrorKind::InvalidConfiguration)) 345 })?; 346 let retry = RhiPublicationRetryPolicy { 347 maximum_attempts, 348 initial_backoff_milliseconds: integer( 349 document, 350 "/publication/retry/initial_backoff_ms", 351 )?, 352 maximum_backoff_milliseconds: integer( 353 document, 354 "/publication/retry/maximum_backoff_ms", 355 )?, 356 attempt_deadline_milliseconds: integer( 357 document, 358 "/publication/retry/attempt_deadline_ms", 359 )?, 360 }; 361 if retry.maximum_attempts == 0 362 || retry.maximum_attempts > RHI_PUBLICATION_MAX_ATTEMPTS 363 || retry.initial_backoff_milliseconds == 0 364 || retry.initial_backoff_milliseconds > retry.maximum_backoff_milliseconds 365 || retry.maximum_backoff_milliseconds > 3_600_000 366 || !(100..=30_000).contains(&retry.attempt_deadline_milliseconds) 367 { 368 return Err(failure(RhiPublicationErrorKind::InvalidConfiguration)); 369 } 370 (targets, Some(retry)) 371 } 372 }; 373 374 let target_set_sha256 = target_set_digest(&targets)?; 375 let authority_sha256 = authority_digest( 376 mode, 377 &target_set_sha256, 378 targets.len(), 379 retry, 380 queue_capacity, 381 )?; 382 Ok(RhiPublicationAuthority { 383 configuration_sha256, 384 mode, 385 targets: targets.into_boxed_slice(), 386 retry, 387 queue_capacity, 388 target_set_sha256, 389 authority_sha256, 390 }) 391 } 392 393 fn target_set_digest(targets: &[RhiPublicationTarget]) -> Result<[u8; 32], RhiPublicationError> { 394 let mut digest = Sha256::new(); 395 digest.update(TARGET_SET_DOMAIN); 396 digest.update( 397 u32::try_from(targets.len()) 398 .map_err(|_| failure(RhiPublicationErrorKind::TargetInventory))? 399 .to_be_bytes(), 400 ); 401 for target in targets { 402 digest.update(u32::from(target.ordinal).to_be_bytes()); 403 digest.update( 404 u64::try_from(target.relay_id.len()) 405 .map_err(|_| failure(RhiPublicationErrorKind::TargetInventory))? 406 .to_be_bytes(), 407 ); 408 digest.update(target.relay_id.as_bytes()); 409 digest.update([u8::from(target.required)]); 410 } 411 Ok(digest.finalize().into()) 412 } 413 414 fn authority_digest( 415 mode: RhiPublicationMode, 416 target_set_sha256: &[u8; 32], 417 target_count: usize, 418 retry: Option<RhiPublicationRetryPolicy>, 419 queue_capacity: u32, 420 ) -> Result<[u8; 32], RhiPublicationError> { 421 let mut digest = Sha256::new(); 422 digest.update(AUTHORITY_DOMAIN); 423 digest.update([match mode { 424 RhiPublicationMode::Disabled => 0, 425 RhiPublicationMode::Required => 1, 426 }]); 427 digest.update( 428 u32::try_from(target_count) 429 .map_err(|_| failure(RhiPublicationErrorKind::TargetInventory))? 430 .to_be_bytes(), 431 ); 432 digest.update(target_set_sha256); 433 match retry { 434 Some(retry) => { 435 digest.update([1]); 436 digest.update(retry.maximum_attempts.to_be_bytes()); 437 digest.update(retry.initial_backoff_milliseconds.to_be_bytes()); 438 digest.update(retry.maximum_backoff_milliseconds.to_be_bytes()); 439 digest.update(retry.attempt_deadline_milliseconds.to_be_bytes()); 440 } 441 None => digest.update([0]), 442 } 443 digest.update(queue_capacity.to_be_bytes()); 444 Ok(digest.finalize().into()) 445 } 446 447 fn string<'a>(document: &'a Value, pointer: &str) -> Result<&'a str, RhiPublicationError> { 448 document 449 .pointer(pointer) 450 .and_then(Value::as_str) 451 .ok_or_else(|| failure(RhiPublicationErrorKind::InvalidConfiguration)) 452 } 453 454 fn integer(document: &Value, pointer: &str) -> Result<u64, RhiPublicationError> { 455 document 456 .pointer(pointer) 457 .and_then(Value::as_u64) 458 .ok_or_else(|| failure(RhiPublicationErrorKind::InvalidConfiguration)) 459 } 460 461 fn valid_relay_id(value: &str) -> bool { 462 !value.is_empty() 463 && value.len() <= 64 464 && value.as_bytes()[0].is_ascii_lowercase() 465 && value.bytes().all(|byte| { 466 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') 467 }) 468 } 469 470 const fn failure(kind: RhiPublicationErrorKind) -> RhiPublicationError { 471 RhiPublicationError { kind } 472 } 473 474 #[cfg(test)] 475 mod tests { 476 use super::*; 477 use crate::{RhiConfigProfile, parse_rhi_config_v1}; 478 479 const EXAMPLE: &[u8] = include_bytes!("../contracts/services_hardening/config.v1.example.toml"); 480 481 #[test] 482 fn required_and_disabled_authority_are_exact_and_deterministic() { 483 let config = parse_rhi_config_v1(EXAMPLE, RhiConfigProfile::Production).expect("config"); 484 let first = RhiPublicationAuthority::from_config(&config).expect("authority"); 485 let second = RhiPublicationAuthority::from_config(&config).expect("authority"); 486 assert_eq!(first, second); 487 assert_eq!(first.mode(), RhiPublicationMode::Required); 488 assert_eq!(first.queue_capacity(), 4_096); 489 assert_eq!(first.targets().len(), 2); 490 assert_eq!(first.targets()[0].ordinal(), 0); 491 assert_eq!(first.targets()[0].relay_id(), "relay-primary"); 492 assert!(first.targets()[0].required()); 493 assert_eq!(first.targets()[1].ordinal(), 1); 494 assert_eq!(first.targets()[1].relay_id(), "relay-secondary"); 495 assert!(!first.targets()[1].required()); 496 let retry = first.retry_policy().expect("required retry"); 497 assert_eq!(retry.maximum_attempts(), 10); 498 assert_eq!(retry.initial_backoff_milliseconds(), 250); 499 assert_eq!(retry.maximum_backoff_milliseconds(), 30_000); 500 assert_eq!(retry.attempt_deadline_milliseconds(), 15_000); 501 assert_ne!(first.target_set_sha256(), &[0; 32]); 502 assert_ne!(first.authority_sha256(), &[0; 32]); 503 504 let source = core::str::from_utf8(EXAMPLE).expect("utf8"); 505 let publication = source.find("[publication]").expect("publication section"); 506 let presence = source.find("[presence]").expect("presence section"); 507 let disabled = format!( 508 "{}[publication]\nmode = \"disabled\"\n\n{}", 509 &source[..publication], 510 &source[presence..] 511 ); 512 let config = parse_rhi_config_v1(disabled.as_bytes(), RhiConfigProfile::Production) 513 .expect("disabled config"); 514 let disabled = RhiPublicationAuthority::from_config(&config).expect("disabled authority"); 515 assert_eq!(disabled.mode(), RhiPublicationMode::Disabled); 516 assert!(disabled.targets().is_empty()); 517 assert_eq!(disabled.retry_policy(), None); 518 assert_eq!(disabled.queue_capacity(), 4_096); 519 assert_ne!(disabled.authority_sha256(), first.authority_sha256()); 520 } 521 522 #[test] 523 fn target_order_requiredness_and_retry_change_the_authority_digest() { 524 let source = core::str::from_utf8(EXAMPLE).expect("utf8"); 525 let baseline = parse_rhi_config_v1(EXAMPLE, RhiConfigProfile::Production).expect("config"); 526 let baseline = RhiPublicationAuthority::from_config(&baseline).expect("authority"); 527 for changed in [ 528 source.replace( 529 "target_relay_ids = [\"relay-primary\", \"relay-secondary\"]", 530 "target_relay_ids = [\"relay-secondary\", \"relay-primary\"]", 531 ), 532 source.replacen("required = true", "required = false", 1), 533 source.replace("max_attempts = 10", "max_attempts = 9"), 534 source.replace("publication = 4096", "publication = 4095"), 535 ] { 536 let config = parse_rhi_config_v1(changed.as_bytes(), RhiConfigProfile::Production) 537 .expect("changed config"); 538 let changed = RhiPublicationAuthority::from_config(&config).expect("authority"); 539 assert_ne!(changed.authority_sha256(), baseline.authority_sha256()); 540 } 541 } 542 543 #[test] 544 fn public_diagnostics_are_source_free_and_redacted() { 545 for kind in [ 546 RhiPublicationErrorKind::InvalidConfiguration, 547 RhiPublicationErrorKind::TargetInventory, 548 ] { 549 let error = failure(kind); 550 assert_eq!(error.kind(), kind); 551 assert!(error.code().starts_with("publication_")); 552 assert!(Error::source(&error).is_none()); 553 let rendered = format!("{error} {error:?}"); 554 assert!(!rendered.contains("relay-secret")); 555 assert!(!rendered.contains("wss://")); 556 } 557 } 558 }