1use std::collections::{BTreeMap, BTreeSet};
10
11#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
13pub struct MultiSourceObjectRef {
14 pub object_id: String,
16 pub merkle_root_hex: String,
18}
19
20impl MultiSourceObjectRef {
21 #[must_use]
23 pub fn new(object_id: impl Into<String>, merkle_root_hex: impl Into<String>) -> Self {
24 Self {
25 object_id: object_id.into(),
26 merkle_root_hex: merkle_root_hex.into(),
27 }
28 }
29
30 fn validate(&self) -> Result<(), MultiSourcePlanError> {
31 if self.object_id.trim().is_empty() {
32 return Err(MultiSourcePlanError::EmptyObjectId);
33 }
34 if self.merkle_root_hex.trim().is_empty() {
35 return Err(MultiSourcePlanError::EmptyMerkleRoot);
36 }
37 Ok(())
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
43pub enum MultiSourceAuth {
44 SymbolAuth { key_id: String },
46 UnauthenticatedLab,
48}
49
50impl MultiSourceAuth {
51 #[must_use]
53 pub fn mode_id(&self) -> &'static str {
54 match self {
55 Self::SymbolAuth { .. } => "symbol_auth",
56 Self::UnauthenticatedLab => "unauthenticated_lab",
57 }
58 }
59
60 fn is_symbol_auth(&self) -> bool {
61 matches!(self, Self::SymbolAuth { .. })
62 }
63
64 fn validate(&self) -> Result<(), MultiSourcePlanError> {
65 match self {
66 Self::SymbolAuth { key_id } if key_id.trim().is_empty() => {
67 Err(MultiSourcePlanError::EmptyAuthKeyId)
68 }
69 _ => Ok(()),
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
76pub struct MultiSourcePeer {
77 pub peer_id: String,
79 pub endpoint: String,
81 pub priority: u32,
83 pub auth: MultiSourceAuth,
85}
86
87impl MultiSourcePeer {
88 #[must_use]
90 pub fn new(
91 peer_id: impl Into<String>,
92 endpoint: impl Into<String>,
93 priority: u32,
94 auth: MultiSourceAuth,
95 ) -> Self {
96 Self {
97 peer_id: peer_id.into(),
98 endpoint: endpoint.into(),
99 priority,
100 auth,
101 }
102 }
103
104 fn validate(&self) -> Result<(), MultiSourcePlanError> {
105 if self.peer_id.trim().is_empty() {
106 return Err(MultiSourcePlanError::EmptyPeerId);
107 }
108 if self.endpoint.trim().is_empty() {
109 return Err(MultiSourcePlanError::EmptyEndpoint {
110 peer_id: self.peer_id.clone(),
111 });
112 }
113 self.auth.validate()
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
119pub enum MultiSourceSymbolBias {
120 SourceFirst,
122 RepairFirst,
124 Balanced,
126}
127
128impl MultiSourceSymbolBias {
129 #[must_use]
131 pub const fn bias_id(self) -> &'static str {
132 match self {
133 Self::SourceFirst => "source_first",
134 Self::RepairFirst => "repair_first",
135 Self::Balanced => "balanced",
136 }
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
142pub struct MultiSourceSelectionConfig {
143 pub min_sources: usize,
145 pub max_sources: usize,
147 pub allow_unauthenticated_lab: bool,
149}
150
151impl MultiSourceSelectionConfig {
152 #[must_use]
154 pub const fn production_default() -> Self {
155 Self {
156 min_sources: 2,
157 max_sources: 4,
158 allow_unauthenticated_lab: false,
159 }
160 }
161
162 fn validate(self) -> Result<(), MultiSourcePlanError> {
163 if self.min_sources == 0 {
164 return Err(MultiSourcePlanError::ZeroMinSources);
165 }
166 if self.max_sources < self.min_sources {
167 return Err(MultiSourcePlanError::MaxSourcesBelowMin {
168 min_sources: self.min_sources,
169 max_sources: self.max_sources,
170 });
171 }
172 Ok(())
173 }
174}
175
176impl Default for MultiSourceSelectionConfig {
177 fn default() -> Self {
178 Self::production_default()
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
184pub struct MultiSourceSourcePlan {
185 pub peer: MultiSourcePeer,
187 pub symbol_bias: MultiSourceSymbolBias,
189 pub selection_order: u32,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
195pub struct MultiSourceStopCommand {
196 pub peer_id: String,
198 pub endpoint: String,
200 pub object_id: String,
202 pub reason: MultiSourceStopReason,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
208pub enum MultiSourceStopReason {
209 DecodedAndVerified,
211 Cancelled,
213 FailedClosed,
215}
216
217impl MultiSourceStopReason {
218 #[must_use]
220 pub const fn reason_id(self) -> &'static str {
221 match self {
222 Self::DecodedAndVerified => "decoded_and_verified",
223 Self::Cancelled => "cancelled",
224 Self::FailedClosed => "failed_closed",
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
231pub struct MultiSourceFetchPlan {
232 pub object: MultiSourceObjectRef,
234 pub selected_sources: Vec<MultiSourceSourcePlan>,
236}
237
238impl MultiSourceFetchPlan {
239 #[must_use]
241 pub fn source_count(&self) -> usize {
242 self.selected_sources.len()
243 }
244
245 #[must_use]
247 pub fn stop_commands(&self, reason: MultiSourceStopReason) -> Vec<MultiSourceStopCommand> {
248 self.selected_sources
249 .iter()
250 .map(|source| MultiSourceStopCommand {
251 peer_id: source.peer.peer_id.clone(),
252 endpoint: source.peer.endpoint.clone(),
253 object_id: self.object.object_id.clone(),
254 reason,
255 })
256 .collect()
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
262pub enum MultiSourcePlanError {
263 #[error("multi-source object id must be non-empty")]
265 EmptyObjectId,
266 #[error("multi-source merkle root must be non-empty")]
268 EmptyMerkleRoot,
269 #[error("multi-source peer id must be non-empty")]
271 EmptyPeerId,
272 #[error("multi-source endpoint for peer {peer_id} must be non-empty")]
274 EmptyEndpoint {
275 peer_id: String,
277 },
278 #[error("multi-source symbol-auth key id must be non-empty")]
280 EmptyAuthKeyId,
281 #[error("multi-source duplicate peer id: {peer_id}")]
283 DuplicatePeerId {
284 peer_id: String,
286 },
287 #[error("multi-source duplicate endpoint: {endpoint}")]
289 DuplicateEndpoint {
290 endpoint: String,
292 },
293 #[error("multi-source peer {peer_id} is unauthenticated but lab mode is not allowed")]
295 UnauthenticatedPeerRejected {
296 peer_id: String,
298 },
299 #[error("multi-source min_sources must be greater than zero")]
301 ZeroMinSources,
302 #[error("multi-source max_sources {max_sources} is below min_sources {min_sources}")]
304 MaxSourcesBelowMin {
305 min_sources: usize,
307 max_sources: usize,
309 },
310 #[error("multi-source needs at least {required} eligible sources, got {available}")]
312 NotEnoughEligibleSources {
313 required: usize,
315 available: usize,
317 },
318 #[error("multi-source selected source count exceeds u32::MAX")]
320 TooManySelectedSources,
321}
322
323pub fn plan_multi_source_fetch(
331 object: MultiSourceObjectRef,
332 peers: impl IntoIterator<Item = MultiSourcePeer>,
333 config: MultiSourceSelectionConfig,
334) -> Result<MultiSourceFetchPlan, MultiSourcePlanError> {
335 object.validate()?;
336 config.validate()?;
337
338 let mut peer_ids = BTreeSet::new();
339 let mut endpoints = BTreeSet::new();
340 let mut by_key = BTreeMap::new();
341
342 for peer in peers {
343 peer.validate()?;
344 if !peer_ids.insert(peer.peer_id.clone()) {
345 return Err(MultiSourcePlanError::DuplicatePeerId {
346 peer_id: peer.peer_id,
347 });
348 }
349 if !endpoints.insert(peer.endpoint.clone()) {
350 return Err(MultiSourcePlanError::DuplicateEndpoint {
351 endpoint: peer.endpoint,
352 });
353 }
354 if !config.allow_unauthenticated_lab && !peer.auth.is_symbol_auth() {
355 return Err(MultiSourcePlanError::UnauthenticatedPeerRejected {
356 peer_id: peer.peer_id,
357 });
358 }
359 by_key.insert(
360 (peer.priority, peer.peer_id.clone(), peer.endpoint.clone()),
361 peer,
362 );
363 }
364
365 if by_key.len() < config.min_sources {
366 return Err(MultiSourcePlanError::NotEnoughEligibleSources {
367 required: config.min_sources,
368 available: by_key.len(),
369 });
370 }
371
372 let mut selected_sources = Vec::new();
373 for (idx, peer) in by_key.into_values().take(config.max_sources).enumerate() {
374 let selection_order =
375 u32::try_from(idx).map_err(|_| MultiSourcePlanError::TooManySelectedSources)?;
376 selected_sources.push(MultiSourceSourcePlan {
377 peer,
378 symbol_bias: symbol_bias_for_order(idx),
379 selection_order,
380 });
381 }
382
383 Ok(MultiSourceFetchPlan {
384 object,
385 selected_sources,
386 })
387}
388
389fn symbol_bias_for_order(order: usize) -> MultiSourceSymbolBias {
390 match order {
391 0 => MultiSourceSymbolBias::SourceFirst,
392 1 => MultiSourceSymbolBias::RepairFirst,
393 _ => MultiSourceSymbolBias::Balanced,
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 fn object_ref() -> MultiSourceObjectRef {
402 MultiSourceObjectRef::new("object-01", "abc123")
403 }
404
405 fn auth(key_id: &str) -> MultiSourceAuth {
406 MultiSourceAuth::SymbolAuth {
407 key_id: key_id.to_string(),
408 }
409 }
410
411 fn peer(id: &str, endpoint: &str, priority: u32) -> MultiSourcePeer {
412 MultiSourcePeer::new(id, endpoint, priority, auth("key-a"))
413 }
414
415 #[test]
416 fn production_default_requires_two_authenticated_sources() {
417 assert_eq!(
418 MultiSourceSelectionConfig::production_default(),
419 MultiSourceSelectionConfig {
420 min_sources: 2,
421 max_sources: 4,
422 allow_unauthenticated_lab: false,
423 }
424 );
425 }
426
427 #[test]
428 fn selection_is_priority_then_peer_then_endpoint_deterministic() {
429 let plan = plan_multi_source_fetch(
430 object_ref(),
431 [
432 peer("peer-c", "10.0.0.3:8472", 10),
433 peer("peer-b", "10.0.0.2:8472", 5),
434 peer("peer-a", "10.0.0.1:8472", 5),
435 ],
436 MultiSourceSelectionConfig {
437 min_sources: 2,
438 max_sources: 2,
439 allow_unauthenticated_lab: false,
440 },
441 )
442 .unwrap();
443
444 assert_eq!(plan.source_count(), 2);
445 assert_eq!(plan.selected_sources[0].peer.peer_id, "peer-a");
446 assert_eq!(plan.selected_sources[1].peer.peer_id, "peer-b");
447 assert_eq!(
448 plan.selected_sources
449 .iter()
450 .map(|source| source.selection_order)
451 .collect::<Vec<_>>(),
452 vec![0, 1]
453 );
454 }
455
456 #[test]
457 fn selected_sources_get_complementary_symbol_biases() {
458 let plan = plan_multi_source_fetch(
459 object_ref(),
460 [
461 peer("peer-a", "10.0.0.1:8472", 1),
462 peer("peer-b", "10.0.0.2:8472", 2),
463 peer("peer-c", "10.0.0.3:8472", 3),
464 ],
465 MultiSourceSelectionConfig {
466 min_sources: 2,
467 max_sources: 3,
468 allow_unauthenticated_lab: false,
469 },
470 )
471 .unwrap();
472
473 assert_eq!(
474 plan.selected_sources
475 .iter()
476 .map(|source| source.symbol_bias)
477 .collect::<Vec<_>>(),
478 vec![
479 MultiSourceSymbolBias::SourceFirst,
480 MultiSourceSymbolBias::RepairFirst,
481 MultiSourceSymbolBias::Balanced,
482 ]
483 );
484 assert_eq!(
485 plan.selected_sources[0].symbol_bias.bias_id(),
486 "source_first"
487 );
488 }
489
490 #[test]
491 fn stop_commands_cover_every_selected_source_in_selection_order() {
492 let plan = plan_multi_source_fetch(
493 object_ref(),
494 [
495 peer("peer-b", "10.0.0.2:8472", 1),
496 peer("peer-a", "10.0.0.1:8472", 0),
497 ],
498 MultiSourceSelectionConfig::production_default(),
499 )
500 .unwrap();
501
502 let stops = plan.stop_commands(MultiSourceStopReason::DecodedAndVerified);
503 assert_eq!(stops.len(), 2);
504 assert_eq!(stops[0].peer_id, "peer-a");
505 assert_eq!(stops[1].peer_id, "peer-b");
506 assert!(stops.iter().all(|stop| {
507 stop.object_id == "object-01" && stop.reason.reason_id() == "decoded_and_verified"
508 }));
509 }
510
511 #[test]
512 fn unauthenticated_sources_fail_closed_outside_lab_mode() {
513 let err = plan_multi_source_fetch(
514 object_ref(),
515 [
516 peer("peer-a", "10.0.0.1:8472", 0),
517 MultiSourcePeer::new(
518 "peer-b",
519 "10.0.0.2:8472",
520 1,
521 MultiSourceAuth::UnauthenticatedLab,
522 ),
523 ],
524 MultiSourceSelectionConfig::production_default(),
525 )
526 .unwrap_err();
527
528 assert!(matches!(
529 err,
530 MultiSourcePlanError::UnauthenticatedPeerRejected { peer_id }
531 if peer_id == "peer-b"
532 ));
533 }
534
535 #[test]
536 fn lab_mode_must_still_meet_minimum_source_count() {
537 let plan = plan_multi_source_fetch(
538 object_ref(),
539 [
540 MultiSourcePeer::new(
541 "peer-a",
542 "10.0.0.1:8472",
543 0,
544 MultiSourceAuth::UnauthenticatedLab,
545 ),
546 MultiSourcePeer::new(
547 "peer-b",
548 "10.0.0.2:8472",
549 1,
550 MultiSourceAuth::UnauthenticatedLab,
551 ),
552 ],
553 MultiSourceSelectionConfig {
554 min_sources: 2,
555 max_sources: 2,
556 allow_unauthenticated_lab: true,
557 },
558 )
559 .unwrap();
560
561 assert_eq!(plan.source_count(), 2);
562 assert_eq!(
563 plan.selected_sources[0].peer.auth.mode_id(),
564 "unauthenticated_lab"
565 );
566 }
567
568 #[test]
569 fn duplicate_peer_ids_and_endpoints_fail_closed() {
570 assert!(matches!(
571 plan_multi_source_fetch(
572 object_ref(),
573 [
574 peer("peer-a", "10.0.0.1:8472", 0),
575 peer("peer-a", "10.0.0.2:8472", 1),
576 ],
577 MultiSourceSelectionConfig::production_default(),
578 ),
579 Err(MultiSourcePlanError::DuplicatePeerId { .. })
580 ));
581
582 assert!(matches!(
583 plan_multi_source_fetch(
584 object_ref(),
585 [
586 peer("peer-a", "10.0.0.1:8472", 0),
587 peer("peer-b", "10.0.0.1:8472", 1),
588 ],
589 MultiSourceSelectionConfig::production_default(),
590 ),
591 Err(MultiSourcePlanError::DuplicateEndpoint { .. })
592 ));
593 }
594
595 #[test]
596 fn invalid_config_and_object_identity_fail_closed() {
597 assert!(matches!(
598 plan_multi_source_fetch(
599 MultiSourceObjectRef::new("", "abc"),
600 [peer("peer-a", "10.0.0.1:8472", 0)],
601 MultiSourceSelectionConfig::production_default(),
602 ),
603 Err(MultiSourcePlanError::EmptyObjectId)
604 ));
605 assert!(matches!(
606 plan_multi_source_fetch(
607 object_ref(),
608 [
609 peer("peer-a", "10.0.0.1:8472", 0),
610 peer("peer-b", "10.0.0.2:8472", 1),
611 ],
612 MultiSourceSelectionConfig {
613 min_sources: 3,
614 max_sources: 2,
615 allow_unauthenticated_lab: false,
616 },
617 ),
618 Err(MultiSourcePlanError::MaxSourcesBelowMin { .. })
619 ));
620 }
621}