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 }