rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

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 }