1pub mod gossip;
4
5use std::collections::BTreeSet;
6
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use oris_evolution::{Capsule, EvolutionEvent, Gene};
12
13#[derive(Clone, Debug, Serialize, Deserialize)]
14pub enum MessageType {
15 Publish,
16 Fetch,
17 Report,
18 Revoke,
19}
20
21#[derive(Clone, Debug, Serialize, Deserialize)]
22#[serde(tag = "kind", rename_all = "snake_case")]
23pub enum NetworkAsset {
24 Gene { gene: Gene },
25 Capsule { capsule: Capsule },
26 EvolutionEvent { event: EvolutionEvent },
27}
28
29#[derive(Clone, Debug, Serialize, Deserialize)]
30pub struct EvolutionEnvelope {
31 pub protocol: String,
32 pub protocol_version: String,
33 pub message_type: MessageType,
34 pub message_id: String,
35 pub sender_id: String,
36 pub timestamp: String,
37 pub assets: Vec<NetworkAsset>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub manifest: Option<EnvelopeManifest>,
40 pub content_hash: String,
41}
42
43#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
44pub struct EnvelopeManifest {
45 pub publisher: String,
46 pub sender_id: String,
47 pub asset_ids: Vec<String>,
48 pub asset_hash: String,
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize)]
52pub struct PublishRequest {
53 pub sender_id: String,
54 pub assets: Vec<NetworkAsset>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub since_cursor: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub resume_token: Option<String>,
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct FetchQuery {
63 pub sender_id: String,
64 pub signals: Vec<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub since_cursor: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub resume_token: Option<String>,
69}
70
71#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
72pub struct SyncAudit {
73 pub batch_id: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub requested_cursor: Option<String>,
76 pub scanned_count: usize,
77 pub applied_count: usize,
78 pub skipped_count: usize,
79 pub failed_count: usize,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub failure_reasons: Vec<String>,
82}
83
84#[derive(Clone, Debug, Serialize, Deserialize)]
85pub struct FetchResponse {
86 pub sender_id: String,
87 pub assets: Vec<NetworkAsset>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub next_cursor: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub resume_token: Option<String>,
92 #[serde(default)]
93 pub sync_audit: SyncAudit,
94}
95
96#[derive(Clone, Debug, Serialize, Deserialize)]
97pub struct RevokeNotice {
98 pub sender_id: String,
99 pub asset_ids: Vec<String>,
100 pub reason: String,
101}
102
103impl EvolutionEnvelope {
104 pub fn publish(sender_id: impl Into<String>, assets: Vec<NetworkAsset>) -> Self {
105 let sender_id = sender_id.into();
106 let manifest = Some(Self::build_manifest(&sender_id, &assets));
107 let mut envelope = Self {
108 protocol: "oen".into(),
109 protocol_version: "0.1".into(),
110 message_type: MessageType::Publish,
111 message_id: format!(
112 "msg-{:x}",
113 Utc::now().timestamp_nanos_opt().unwrap_or_default()
114 ),
115 sender_id,
116 timestamp: Utc::now().to_rfc3339(),
117 assets,
118 manifest,
119 content_hash: String::new(),
120 };
121 envelope.content_hash = envelope.compute_content_hash();
122 envelope
123 }
124
125 fn build_manifest(sender_id: &str, assets: &[NetworkAsset]) -> EnvelopeManifest {
126 EnvelopeManifest {
127 publisher: sender_id.to_string(),
128 sender_id: sender_id.to_string(),
129 asset_ids: Self::manifest_asset_ids(assets),
130 asset_hash: Self::compute_assets_hash(assets),
131 }
132 }
133
134 fn normalize_manifest_ids(asset_ids: &[String]) -> Vec<String> {
135 let normalized = asset_ids
136 .iter()
137 .map(|value| value.trim())
138 .filter(|value| !value.is_empty())
139 .map(ToOwned::to_owned)
140 .collect::<BTreeSet<_>>();
141 normalized.into_iter().collect()
142 }
143
144 pub fn manifest_asset_ids(assets: &[NetworkAsset]) -> Vec<String> {
145 let ids = assets
146 .iter()
147 .map(Self::manifest_asset_id)
148 .collect::<BTreeSet<_>>();
149 ids.into_iter().collect()
150 }
151
152 fn manifest_asset_id(asset: &NetworkAsset) -> String {
153 match asset {
154 NetworkAsset::Gene { gene } => format!("gene:{}", gene.id),
155 NetworkAsset::Capsule { capsule } => format!("capsule:{}", capsule.id),
156 NetworkAsset::EvolutionEvent { event } => {
157 let payload = serde_json::to_vec(event).unwrap_or_default();
158 let mut hasher = Sha256::new();
159 hasher.update(payload);
160 format!("event:{}", hex::encode(hasher.finalize()))
161 }
162 }
163 }
164
165 pub fn compute_assets_hash(assets: &[NetworkAsset]) -> String {
166 let payload = serde_json::to_vec(assets).unwrap_or_default();
167 let mut hasher = Sha256::new();
168 hasher.update(payload);
169 hex::encode(hasher.finalize())
170 }
171
172 pub fn compute_content_hash(&self) -> String {
173 let payload = (
174 &self.protocol,
175 &self.protocol_version,
176 &self.message_type,
177 &self.message_id,
178 &self.sender_id,
179 &self.timestamp,
180 &self.assets,
181 &self.manifest,
182 );
183 let json = serde_json::to_vec(&payload).unwrap_or_default();
184 let mut hasher = Sha256::new();
185 hasher.update(json);
186 hex::encode(hasher.finalize())
187 }
188
189 pub fn verify_content_hash(&self) -> bool {
190 self.compute_content_hash() == self.content_hash
191 }
192
193 pub fn verify_manifest(&self) -> Result<(), String> {
194 let Some(manifest) = self.manifest.as_ref() else {
195 return Err("missing manifest".into());
196 };
197 if manifest.publisher.trim().is_empty() {
198 return Err("missing manifest publisher".into());
199 }
200 if manifest.sender_id.trim().is_empty() {
201 return Err("missing manifest sender_id".into());
202 }
203 if manifest.sender_id.trim() != self.sender_id.trim() {
204 return Err("manifest sender_id mismatch".into());
205 }
206
207 let expected_asset_ids = Self::manifest_asset_ids(&self.assets);
208 let actual_asset_ids = Self::normalize_manifest_ids(&manifest.asset_ids);
209 if expected_asset_ids != actual_asset_ids {
210 return Err("manifest asset_ids mismatch".into());
211 }
212
213 let expected_hash = Self::compute_assets_hash(&self.assets);
214 if manifest.asset_hash != expected_hash {
215 return Err("manifest asset_hash mismatch".into());
216 }
217
218 Ok(())
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use oris_evolution::{AssetState, Gene};
226
227 fn sample_gene(id: &str) -> Gene {
228 Gene {
229 id: id.to_string(),
230 signals: vec!["docs.fix".to_string()],
231 strategy: vec!["summary=docs fix".to_string()],
232 validation: vec!["cargo test".to_string()],
233 state: AssetState::Promoted,
234 }
235 }
236
237 #[test]
238 fn publish_populates_manifest_and_verifies() {
239 let envelope = EvolutionEnvelope::publish(
240 "node-a",
241 vec![NetworkAsset::Gene {
242 gene: sample_gene("gene-a"),
243 }],
244 );
245 assert!(envelope.verify_content_hash());
246 assert!(envelope.verify_manifest().is_ok());
247 assert!(envelope.manifest.is_some());
248 }
249
250 #[test]
251 fn verify_manifest_detects_sender_mismatch() {
252 let mut envelope = EvolutionEnvelope::publish(
253 "node-a",
254 vec![NetworkAsset::Gene {
255 gene: sample_gene("gene-a"),
256 }],
257 );
258 envelope.sender_id = "node-b".to_string();
259 assert!(envelope.verify_manifest().is_err());
260 }
261
262 #[test]
263 fn verify_manifest_detects_asset_hash_drift() {
264 let mut envelope = EvolutionEnvelope::publish(
265 "node-a",
266 vec![NetworkAsset::Gene {
267 gene: sample_gene("gene-a"),
268 }],
269 );
270 if let Some(NetworkAsset::Gene { gene }) = envelope.assets.first_mut() {
271 gene.id = "gene-b".to_string();
272 }
273 assert!(envelope.verify_manifest().is_err());
274 }
275}