rhi

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

publication_submission.rs (10763B)


      1 //! Exact committed publication bytes for later relay submission.
      2 
      3 use core::fmt;
      4 use std::error::Error;
      5 
      6 use radroots_service_sqlite::{ServiceSqliteTransaction, ServiceSqliteTransactionError};
      7 use sha2::{Digest as _, Sha256};
      8 use sqlx::Row as _;
      9 
     10 use crate::RhiPublicationOutboxRepository;
     11 
     12 /// Exact version of the committed publication submission contract.
     13 pub const RHI_PUBLICATION_SUBMISSION_CONTRACT_VERSION: u32 = 1;
     14 
     15 const SIGNED_EVENT_BYTES_MAXIMUM: usize = 32_768;
     16 
     17 const READ_COMMITTED_PUBLICATION_SQL: &str = r#"SELECT
     18     CASE WHEN typeof(outbox.outbox_id) = 'blob' AND length(outbox.outbox_id) = 32
     19         THEN outbox.outbox_id ELSE NULL END AS outbox_id,
     20     CASE WHEN typeof(outbox.event_id) = 'blob' AND length(outbox.event_id) = 32
     21         THEN outbox.event_id ELSE NULL END AS outbox_event_id,
     22     CASE WHEN typeof(outbox.event_sha256) = 'blob' AND length(outbox.event_sha256) = 32
     23         THEN outbox.event_sha256 ELSE NULL END AS outbox_event_sha256,
     24     CASE WHEN typeof(event.event_id) = 'blob' AND length(event.event_id) = 32
     25         THEN event.event_id ELSE NULL END AS event_id,
     26     CASE WHEN typeof(event.event_sha256) = 'blob' AND length(event.event_sha256) = 32
     27         THEN event.event_sha256 ELSE NULL END AS event_sha256,
     28     CASE
     29         WHEN typeof(event.canonical_event_json) = 'blob'
     30             AND length(event.canonical_event_json) BETWEEN 1 AND 32768
     31         THEN event.canonical_event_json
     32         ELSE NULL
     33     END AS exact_signed_event_bytes
     34 FROM publication_outbox AS outbox
     35 LEFT JOIN signed_attestation_events AS event ON event.event_id = outbox.event_id
     36 WHERE outbox.outbox_id = ?
     37 LIMIT 2"#;
     38 
     39 /// Stable source-free committed-publication read failure class.
     40 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     41 pub enum RhiPublicationSubmissionErrorKind {
     42     NotFound,
     43     Binding,
     44     Storage,
     45 }
     46 
     47 impl RhiPublicationSubmissionErrorKind {
     48     /// Returns the stable machine-readable failure code.
     49     #[must_use]
     50     pub const fn code(self) -> &'static str {
     51         match self {
     52             Self::NotFound => "publication_submission_not_found",
     53             Self::Binding => "publication_submission_binding_invalid",
     54             Self::Storage => "publication_submission_storage_failed",
     55         }
     56     }
     57 }
     58 
     59 /// Redacted source-free committed-publication read failure.
     60 #[derive(Clone, Copy, PartialEq, Eq)]
     61 pub struct RhiPublicationSubmissionError {
     62     kind: RhiPublicationSubmissionErrorKind,
     63 }
     64 
     65 impl RhiPublicationSubmissionError {
     66     /// Returns the stable failure class.
     67     #[must_use]
     68     pub const fn kind(self) -> RhiPublicationSubmissionErrorKind {
     69         self.kind
     70     }
     71 
     72     /// Returns the stable machine-readable failure code.
     73     #[must_use]
     74     pub const fn code(self) -> &'static str {
     75         self.kind.code()
     76     }
     77 }
     78 
     79 impl fmt::Display for RhiPublicationSubmissionError {
     80     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     81         formatter.write_str(match self.kind {
     82             RhiPublicationSubmissionErrorKind::NotFound => {
     83                 "RHI committed publication was not found"
     84             }
     85             RhiPublicationSubmissionErrorKind::Binding => {
     86                 "RHI committed publication binding is invalid"
     87             }
     88             RhiPublicationSubmissionErrorKind::Storage => {
     89                 "RHI committed publication could not be read"
     90             }
     91         })
     92     }
     93 }
     94 
     95 impl fmt::Debug for RhiPublicationSubmissionError {
     96     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     97         formatter
     98             .debug_struct("RhiPublicationSubmissionError")
     99             .field("kind", &self.kind)
    100             .finish()
    101     }
    102 }
    103 
    104 impl Error for RhiPublicationSubmissionError {}
    105 
    106 /// Stable opaque identity of one immutable publication outbox.
    107 #[derive(Clone, Copy, PartialEq, Eq, Hash)]
    108 pub struct RhiPublicationOutboxId([u8; 32]);
    109 
    110 impl RhiPublicationOutboxId {
    111     pub(crate) const fn from_committed_bytes(bytes: [u8; 32]) -> Self {
    112         Self(bytes)
    113     }
    114 
    115     /// Returns the exact identity bytes.
    116     #[must_use]
    117     pub const fn as_bytes(&self) -> &[u8; 32] {
    118         &self.0
    119     }
    120 }
    121 
    122 impl fmt::Debug for RhiPublicationOutboxId {
    123     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    124         formatter.write_str("RhiPublicationOutboxId([redacted])")
    125     }
    126 }
    127 
    128 /// Sealed exact event bytes read from one immutable committed outbox.
    129 ///
    130 /// This value is durable evidence, not a relay claim or lease. Later
    131 /// submission code may borrow [`Self::exact_signed_event_bytes`] but must not
    132 /// parse, rebuild, reserialize, or re-sign it.
    133 ///
    134 /// ```compile_fail
    135 /// use rhi::{RhiCommittedPublication, RhiPublicationOutboxId};
    136 ///
    137 /// let _forged = RhiCommittedPublication {
    138 ///     outbox_id: RhiPublicationOutboxId::from_committed_bytes([0; 32]),
    139 ///     event_id: [0; 32],
    140 ///     event_sha256: [0; 32],
    141 ///     exact_signed_event_bytes: Box::new([]),
    142 /// };
    143 /// ```
    144 #[must_use = "committed publication bytes must be submitted exactly or deliberately discarded"]
    145 pub struct RhiCommittedPublication {
    146     outbox_id: RhiPublicationOutboxId,
    147     event_id: [u8; 32],
    148     event_sha256: [u8; 32],
    149     exact_signed_event_bytes: Box<[u8]>,
    150 }
    151 
    152 impl RhiCommittedPublication {
    153     #[cfg(test)]
    154     pub(crate) fn test_fixture(
    155         outbox_id: RhiPublicationOutboxId,
    156         event_id: [u8; 32],
    157         event_sha256: [u8; 32],
    158         exact_signed_event_bytes: Box<[u8]>,
    159     ) -> Self {
    160         Self {
    161             outbox_id,
    162             event_id,
    163             event_sha256,
    164             exact_signed_event_bytes,
    165         }
    166     }
    167 
    168     /// Returns the immutable outbox identity.
    169     #[must_use]
    170     pub const fn outbox_id(&self) -> RhiPublicationOutboxId {
    171         self.outbox_id
    172     }
    173 
    174     /// Returns the independently verified Nostr event identifier.
    175     #[must_use]
    176     pub const fn event_id(&self) -> &[u8; 32] {
    177         &self.event_id
    178     }
    179 
    180     /// Returns the SHA-256 digest of the exact stored signed-event bytes.
    181     #[must_use]
    182     pub const fn event_sha256(&self) -> &[u8; 32] {
    183         &self.event_sha256
    184     }
    185 
    186     /// Returns the exact committed signed-event bytes without transformation.
    187     #[must_use]
    188     pub const fn exact_signed_event_bytes(&self) -> &[u8] {
    189         &self.exact_signed_event_bytes
    190     }
    191 }
    192 
    193 impl fmt::Debug for RhiCommittedPublication {
    194     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    195         formatter.write_str("RhiCommittedPublication([redacted])")
    196     }
    197 }
    198 
    199 impl RhiPublicationOutboxRepository<'_> {
    200     /// Reads the exact committed signed bytes for one immutable outbox.
    201     ///
    202     /// The operation performs one bounded read-only SQLx transaction and no
    203     /// JSON/event decoding, signing, relay, network, filesystem, clock,
    204     /// entropy, or task operation. Repeated reads and reads after reopen
    205     /// return the same bytes or fail closed.
    206     pub async fn read_committed_publication(
    207         &self,
    208         outbox_id: RhiPublicationOutboxId,
    209     ) -> Result<RhiCommittedPublication, RhiPublicationSubmissionError> {
    210         self.host()
    211             .sqlite_host()
    212             .transaction(move |transaction| {
    213                 Box::pin(async move { read_committed(transaction, outbox_id).await })
    214             })
    215             .await
    216             .map_err(map_transaction_error)
    217     }
    218 }
    219 
    220 pub(crate) async fn read_committed(
    221     transaction: &mut ServiceSqliteTransaction<'_>,
    222     requested_outbox_id: RhiPublicationOutboxId,
    223 ) -> Result<RhiCommittedPublication, ReadError> {
    224     let rows = sqlx::query(READ_COMMITTED_PUBLICATION_SQL)
    225         .bind(requested_outbox_id.as_bytes().as_slice())
    226         .fetch_all(&mut *transaction)
    227         .await
    228         .map_err(|_| ReadError::Storage)?;
    229     let [row] = rows.as_slice() else {
    230         return if rows.is_empty() {
    231             Err(ReadError::NotFound)
    232         } else {
    233             Err(ReadError::Binding)
    234         };
    235     };
    236     let outbox_id = blob32(row, "outbox_id")?;
    237     let outbox_event_id = blob32(row, "outbox_event_id")?;
    238     let outbox_event_sha256 = blob32(row, "outbox_event_sha256")?;
    239     let event_id = blob32(row, "event_id")?;
    240     let event_sha256 = blob32(row, "event_sha256")?;
    241     let exact_signed_event_bytes = row
    242         .try_get::<Option<Vec<u8>>, _>("exact_signed_event_bytes")
    243         .map_err(|_| ReadError::Binding)?
    244         .ok_or(ReadError::Binding)?;
    245     if outbox_id != *requested_outbox_id.as_bytes()
    246         || outbox_event_id != event_id
    247         || outbox_event_sha256 != event_sha256
    248         || exact_signed_event_bytes.is_empty()
    249         || exact_signed_event_bytes.len() > SIGNED_EVENT_BYTES_MAXIMUM
    250         || <[u8; 32]>::from(Sha256::digest(&exact_signed_event_bytes)) != event_sha256
    251     {
    252         return Err(ReadError::Binding);
    253     }
    254     Ok(RhiCommittedPublication {
    255         outbox_id: RhiPublicationOutboxId::from_committed_bytes(outbox_id),
    256         event_id,
    257         event_sha256,
    258         exact_signed_event_bytes: exact_signed_event_bytes.into_boxed_slice(),
    259     })
    260 }
    261 
    262 fn blob32(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<[u8; 32], ReadError> {
    263     row.try_get::<Option<Vec<u8>>, _>(column)
    264         .map_err(|_| ReadError::Binding)?
    265         .ok_or(ReadError::Binding)?
    266         .try_into()
    267         .map_err(|_| ReadError::Binding)
    268 }
    269 
    270 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    271 pub(crate) enum ReadError {
    272     NotFound,
    273     Binding,
    274     Storage,
    275 }
    276 
    277 fn map_transaction_error(
    278     error: ServiceSqliteTransactionError<ReadError>,
    279 ) -> RhiPublicationSubmissionError {
    280     failure(match error.operation_error().copied() {
    281         Some(ReadError::NotFound) => RhiPublicationSubmissionErrorKind::NotFound,
    282         Some(ReadError::Binding) => RhiPublicationSubmissionErrorKind::Binding,
    283         Some(ReadError::Storage) | None => RhiPublicationSubmissionErrorKind::Storage,
    284     })
    285 }
    286 
    287 const fn failure(kind: RhiPublicationSubmissionErrorKind) -> RhiPublicationSubmissionError {
    288     RhiPublicationSubmissionError { kind }
    289 }
    290 
    291 #[cfg(test)]
    292 mod tests {
    293     use super::*;
    294 
    295     #[test]
    296     fn errors_and_capabilities_are_redacted() {
    297         for kind in [
    298             RhiPublicationSubmissionErrorKind::NotFound,
    299             RhiPublicationSubmissionErrorKind::Binding,
    300             RhiPublicationSubmissionErrorKind::Storage,
    301         ] {
    302             let error = failure(kind);
    303             assert_eq!(error.kind(), kind);
    304             assert!(error.code().starts_with("publication_submission_"));
    305             assert!(Error::source(&error).is_none());
    306             let rendered = format!("{error} {error:?}");
    307             assert!(!rendered.contains("relay-primary"));
    308             assert!(!rendered.contains("SELECT"));
    309         }
    310         let id = RhiPublicationOutboxId::from_committed_bytes([0x51; 32]);
    311         assert_eq!(format!("{id:?}"), "RhiPublicationOutboxId([redacted])");
    312     }
    313 }