Skip to main content

softchat/
relay_catalog.rs

1//! Relay catalog reconciliation, failover ordering, and retry policy.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::SoftchatError;
9
10/// Maximum effective relay records retained for one account.
11pub const MAX_RELAY_CATALOG_ENTRIES: usize = 256;
12/// Maximum authenticated DNS URI records accepted in one refresh.
13pub const MAX_DNS_RELAY_RECORDS: usize = 256;
14/// Default refresh lifetime assigned to the compiled fallback.
15pub const DEFAULT_FALLBACK_RELAY_TTL_SECONDS: u32 = 3_600;
16/// Maximum DNS URI TTL accepted by the portable policy.
17pub const MAX_RELAY_TTL_SECONDS: u32 = 7 * 24 * 60 * 60;
18/// Maximum reconnect delay before host-selected jitter.
19pub const MAX_RELAY_RETRY_DELAY_MS: u64 = 8_000;
20/// Host-selected retry jitter range is always zero through this value.
21pub const MAX_RELAY_RETRY_JITTER_MS: u64 = 250;
22
23/// Consumer-visible source of one relay.
24#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[serde(rename_all = "camelCase")]
26#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
27pub enum RelayCatalogSource {
28    /// Authenticated discovery or the compiled fallback.
29    Automatic,
30    /// Explicit user configuration.
31    Custom,
32}
33
34/// Complete platform-persisted relay catalog row.
35#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
38pub struct RelayCatalogEntry {
39    /// Canonical WSS URL and stable row ID.
40    pub url: String,
41    /// Portable creation timestamp.
42    pub created_at: i64,
43    /// Whether this is the single active relay.
44    pub is_active: bool,
45    /// Consumer-visible source.
46    pub source: RelayCatalogSource,
47    /// Whether the latest authenticated discovery contains this URL.
48    pub is_dns_discovered: bool,
49    /// Whether the user excluded an automatically discovered URL.
50    pub is_user_excluded: bool,
51    /// DNS priority, or `-1` when absent.
52    pub dns_priority: i32,
53    /// DNS weight, or `-1` when absent.
54    pub dns_weight: i32,
55    /// Absolute portable DNS expiration, or `-1` when absent.
56    pub dns_expires_at: i64,
57}
58
59/// One authenticated DNS URI record supplied by the Android system adapter.
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[serde(rename_all = "camelCase", deny_unknown_fields)]
62#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
63pub struct DnsRelayRecord {
64    /// WSS or pinned `wss+noise` target URL.
65    pub target: String,
66    /// RFC 7553 URI priority represented as an unsigned 16-bit value.
67    pub priority: u32,
68    /// RFC 7553 URI weight represented as an unsigned 16-bit value.
69    pub weight: u32,
70    /// DNS TTL in seconds.
71    pub ttl_seconds: u32,
72}
73
74/// Validated system-transport plan for one configured relay URL.
75#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
76#[serde(rename_all = "camelCase")]
77#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
78pub struct RelayEndpointPlan {
79    /// Canonical persisted URL; Noise endpoints use `wss+noise` and a lowercase key fragment.
80    pub configured_url: String,
81    /// Ordinary `wss` URL used by managed or low-level host transport.
82    pub network_url: String,
83    /// Pinned Noise responder static key, empty for ordinary TLS-only WSS.
84    pub noise_remote_static_key: Vec<u8>,
85}
86
87/// Deterministic full-state catalog replacement.
88#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
89#[serde(rename_all = "camelCase")]
90#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
91pub struct RelayCatalogPlan {
92    /// Complete rows the platform commits atomically.
93    pub entries: Vec<RelayCatalogEntry>,
94    /// Selected active URL.
95    pub active_relay_url: String,
96    /// Earliest authenticated discovery expiration.
97    pub next_refresh_at: i64,
98    /// Whether unauthenticated/empty discovery activated the fallback.
99    pub used_fallback: bool,
100}
101
102/// Transport failure class consumed by the Rust retry policy.
103#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
104#[serde(rename_all = "camelCase")]
105#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
106pub enum RelayFailureKind {
107    /// Android reports that no usable network currently exists.
108    NetworkUnavailable,
109    /// Connection loss or one transient relay failure.
110    Temporary,
111    /// Relay asks the client to slow down.
112    RateLimited,
113    /// Authentication permanently failed for this relay.
114    Authentication,
115    /// Protocol behavior makes the relay unusable for this session.
116    ProtocolPermanent,
117}
118
119/// Retry/failover action selected by Rust.
120#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
121#[serde(rename_all = "camelCase")]
122#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
123pub enum RelayRetryAction {
124    /// Retain the same relay and wait for the platform/network.
125    WaitSameRelay,
126    /// Select the next effective relay immediately after the delay.
127    RotateRelay,
128}
129
130/// Bounded retry plan; the host chooses jitter inside the declared range.
131#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
132#[serde(rename_all = "camelCase")]
133#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
134pub struct RelayRetryPlan {
135    /// Retry or failover behavior.
136    pub action: RelayRetryAction,
137    /// Exponential delay before jitter.
138    pub delay_ms: u64,
139    /// Inclusive maximum host-selected jitter.
140    pub jitter_max_ms: u64,
141}
142
143/// Stateless relay catalog reducer.
144#[derive(Clone, Copy, Debug, Default)]
145pub struct RelayCatalogReducer;
146
147impl RelayCatalogReducer {
148    /// Reconcile persisted custom/exclusion state with authenticated discovery.
149    ///
150    /// Discovery that is not explicitly authenticated never becomes trusted
151    /// catalog state. The compiled fallback is used instead.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`SoftchatError::InvalidRelayCatalog`] for invalid URLs,
156    /// duplicate persisted rows, bounds, timestamps, priorities, weights, or
157    /// TTLs.
158    pub fn reconcile(
159        current: Vec<RelayCatalogEntry>,
160        discovered: Vec<DnsRelayRecord>,
161        refreshed_at: i64,
162        fallback_url: String,
163        dns_authenticated: bool,
164    ) -> Result<RelayCatalogPlan, SoftchatError> {
165        if refreshed_at < 0
166            || current.len() > MAX_RELAY_CATALOG_ENTRIES
167            || discovered.len() > MAX_DNS_RELAY_RECORDS
168        {
169            return Err(SoftchatError::InvalidRelayCatalog);
170        }
171        let fallback_url = canonical_relay_url(&fallback_url)?;
172        let mut rows = BTreeMap::new();
173        for mut entry in current {
174            validate_entry(&entry)?;
175            entry.url = canonical_relay_url(&entry.url)?;
176            if rows.insert(entry.url.clone(), entry).is_some() {
177                return Err(SoftchatError::InvalidRelayCatalog);
178            }
179        }
180
181        for entry in rows.values_mut() {
182            entry.is_dns_discovered = false;
183            entry.dns_priority = -1;
184            entry.dns_weight = -1;
185            entry.dns_expires_at = -1;
186        }
187
188        let preferred = if dns_authenticated && !discovered.is_empty() {
189            preferred_dns_records(discovered)?
190        } else {
191            vec![DnsRelayRecord {
192                target: fallback_url.clone(),
193                priority: 0,
194                weight: 0,
195                ttl_seconds: DEFAULT_FALLBACK_RELAY_TTL_SECONDS,
196            }]
197        };
198        let used_fallback = !(dns_authenticated && !preferred.is_empty());
199
200        for record in preferred {
201            let target = canonical_relay_url(&record.target)?;
202            validate_dns_record(&record)?;
203            let expires_at = refreshed_at
204                .checked_add(i64::from(record.ttl_seconds))
205                .ok_or(SoftchatError::InvalidRelayCatalog)?;
206            let entry = rows
207                .entry(target.clone())
208                .or_insert_with(|| RelayCatalogEntry {
209                    url: target,
210                    created_at: refreshed_at,
211                    is_active: false,
212                    source: RelayCatalogSource::Automatic,
213                    is_dns_discovered: false,
214                    is_user_excluded: false,
215                    dns_priority: -1,
216                    dns_weight: -1,
217                    dns_expires_at: -1,
218                });
219            entry.is_dns_discovered = true;
220            entry.dns_priority =
221                i32::try_from(record.priority).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
222            entry.dns_weight =
223                i32::try_from(record.weight).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
224            entry.dns_expires_at = expires_at;
225        }
226
227        rows.retain(|_, entry| {
228            entry.source == RelayCatalogSource::Custom
229                || entry.is_dns_discovered
230                || entry.is_user_excluded
231        });
232        if rows.len() > MAX_RELAY_CATALOG_ENTRIES {
233            return Err(SoftchatError::InvalidRelayCatalog);
234        }
235
236        let previous_active = rows
237            .values()
238            .find(|entry| entry.is_active && is_effective(entry))
239            .map(|entry| entry.url.clone());
240        for entry in rows.values_mut() {
241            entry.is_active = false;
242        }
243        let ordered = ordered_effective_entries(rows.values().cloned().collect())?;
244        let active_relay_url = previous_active
245            .or_else(|| {
246                ordered
247                    .iter()
248                    .find(|entry| entry.url == fallback_url)
249                    .map(|entry| entry.url.clone())
250            })
251            .or_else(|| ordered.first().map(|entry| entry.url.clone()))
252            .ok_or(SoftchatError::InvalidRelayCatalog)?;
253        let active = rows
254            .get_mut(&active_relay_url)
255            .ok_or(SoftchatError::InvalidRelayCatalog)?;
256        active.is_active = true;
257
258        let mut entries = rows.into_values().collect::<Vec<_>>();
259        entries.sort_by(entry_storage_order);
260        let next_refresh_at = entries
261            .iter()
262            .filter(|entry| entry.is_dns_discovered)
263            .map(|entry| entry.dns_expires_at)
264            .min()
265            .ok_or(SoftchatError::InvalidRelayCatalog)?;
266        Ok(RelayCatalogPlan {
267            entries,
268            active_relay_url,
269            next_refresh_at,
270            used_fallback,
271        })
272    }
273
274    /// Add or reactivate a user-owned relay without discarding DNS metadata.
275    ///
276    /// # Errors
277    ///
278    /// Returns a stable relay-catalog error for malformed state or URL.
279    pub fn add_custom(
280        current: Vec<RelayCatalogEntry>,
281        relay_url: String,
282        created_at: i64,
283    ) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
284        if created_at < 0 || current.len() >= MAX_RELAY_CATALOG_ENTRIES {
285            return Err(SoftchatError::InvalidRelayCatalog);
286        }
287        let relay_url = canonical_relay_url(&relay_url)?;
288        let mut rows = validated_rows(current)?;
289        let entry = rows
290            .entry(relay_url.clone())
291            .or_insert_with(|| RelayCatalogEntry {
292                url: relay_url,
293                created_at,
294                is_active: false,
295                source: RelayCatalogSource::Custom,
296                is_dns_discovered: false,
297                is_user_excluded: false,
298                dns_priority: -1,
299                dns_weight: -1,
300                dns_expires_at: -1,
301            });
302        entry.source = RelayCatalogSource::Custom;
303        entry.is_user_excluded = false;
304        Ok(sorted_rows(rows))
305    }
306
307    /// Exclude an automatic relay or remove the custom ownership of a URL.
308    ///
309    /// # Errors
310    ///
311    /// Returns a stable relay-catalog error for an unknown or active-only URL.
312    pub fn exclude(
313        current: Vec<RelayCatalogEntry>,
314        relay_url: String,
315    ) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
316        let relay_url = canonical_relay_url(&relay_url)?;
317        let mut rows = validated_rows(current)?;
318        let entry = rows
319            .get_mut(&relay_url)
320            .ok_or(SoftchatError::InvalidRelayCatalog)?;
321        entry.source = RelayCatalogSource::Automatic;
322        entry.is_user_excluded = true;
323        entry.is_active = false;
324        if !entry.is_dns_discovered {
325            rows.remove(&relay_url);
326        }
327        ensure_one_active(&mut rows)?;
328        Ok(sorted_rows(rows))
329    }
330
331    /// Restore every currently discovered automatic relay.
332    ///
333    /// # Errors
334    ///
335    /// Returns a stable relay-catalog error for malformed persisted rows.
336    pub fn restore_automatic(
337        current: Vec<RelayCatalogEntry>,
338    ) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
339        let mut rows = validated_rows(current)?;
340        for entry in rows.values_mut() {
341            if entry.is_dns_discovered {
342                entry.is_user_excluded = false;
343            }
344        }
345        ensure_one_active(&mut rows)?;
346        Ok(sorted_rows(rows))
347    }
348
349    /// Select one effective active relay.
350    ///
351    /// # Errors
352    ///
353    /// Returns a stable relay-catalog error for an unknown/excluded target.
354    pub fn set_active(
355        current: Vec<RelayCatalogEntry>,
356        relay_url: String,
357    ) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
358        let relay_url = canonical_relay_url(&relay_url)?;
359        let mut rows = validated_rows(current)?;
360        if rows
361            .get(&relay_url)
362            .is_none_or(|entry| !is_effective(entry))
363        {
364            return Err(SoftchatError::InvalidRelayCatalog);
365        }
366        for entry in rows.values_mut() {
367            entry.is_active = entry.url == relay_url;
368        }
369        Ok(sorted_rows(rows))
370    }
371
372    /// Return effective failover URLs ordered by DNS priority then stable URL.
373    ///
374    /// The failed URL is moved to the end rather than removed so a caller can
375    /// continue cycling after every candidate fails.
376    ///
377    /// # Errors
378    ///
379    /// Returns a stable relay-catalog error for malformed persisted rows.
380    pub fn failover_candidates(
381        current: Vec<RelayCatalogEntry>,
382        failed_url: String,
383    ) -> Result<Vec<String>, SoftchatError> {
384        let failed_url = canonical_relay_url(&failed_url)?;
385        let rows = validated_rows(current)?;
386        let mut urls = ordered_effective_entries(rows.into_values().collect())?
387            .into_iter()
388            .map(|entry| entry.url)
389            .collect::<Vec<_>>();
390        if let Some(index) = urls.iter().position(|url| url == &failed_url) {
391            let failed = urls.remove(index);
392            urls.push(failed);
393        }
394        Ok(urls)
395    }
396}
397
398/// Select the exact retry/failover behavior for one failed attempt.
399#[must_use]
400pub fn plan_relay_retry(
401    attempt: u32,
402    failure: RelayFailureKind,
403    bypass_delay_once: bool,
404) -> RelayRetryPlan {
405    let action = match failure {
406        RelayFailureKind::Authentication | RelayFailureKind::ProtocolPermanent => {
407            RelayRetryAction::RotateRelay
408        }
409        RelayFailureKind::NetworkUnavailable
410        | RelayFailureKind::Temporary
411        | RelayFailureKind::RateLimited => RelayRetryAction::WaitSameRelay,
412    };
413    let delay_ms = if bypass_delay_once
414        || matches!(
415            failure,
416            RelayFailureKind::Authentication | RelayFailureKind::ProtocolPermanent
417        ) {
418        0
419    } else {
420        let exponent = attempt.min(4);
421        let calculated = 500_u64.saturating_mul(1_u64 << exponent);
422        if calculated > MAX_RELAY_RETRY_DELAY_MS {
423            MAX_RELAY_RETRY_DELAY_MS
424        } else {
425            calculated
426        }
427    };
428    RelayRetryPlan {
429        action,
430        delay_ms,
431        jitter_max_ms: MAX_RELAY_RETRY_JITTER_MS,
432    }
433}
434
435fn preferred_dns_records(
436    records: Vec<DnsRelayRecord>,
437) -> Result<Vec<DnsRelayRecord>, SoftchatError> {
438    let mut preferred = BTreeMap::new();
439    for mut record in records {
440        validate_dns_record(&record)?;
441        record.target = canonical_relay_url(&record.target)?;
442        match preferred.get(&record.target) {
443            None => {
444                preferred.insert(record.target.clone(), record);
445            }
446            Some(existing) if is_preferred(&record, existing) => {
447                preferred.insert(record.target.clone(), record);
448            }
449            Some(_) => {}
450        }
451    }
452    Ok(preferred.into_values().collect())
453}
454
455fn is_preferred(candidate: &DnsRelayRecord, existing: &DnsRelayRecord) -> bool {
456    candidate.priority < existing.priority
457        || (candidate.priority == existing.priority && candidate.weight > existing.weight)
458        || (candidate.priority == existing.priority
459            && candidate.weight == existing.weight
460            && candidate.ttl_seconds < existing.ttl_seconds)
461}
462
463fn validate_dns_record(record: &DnsRelayRecord) -> Result<(), SoftchatError> {
464    if record.priority > u32::from(u16::MAX)
465        || record.weight > u32::from(u16::MAX)
466        || record.ttl_seconds == 0
467        || record.ttl_seconds > MAX_RELAY_TTL_SECONDS
468    {
469        Err(SoftchatError::InvalidRelayCatalog)
470    } else {
471        Ok(())
472    }
473}
474
475fn validate_entry(entry: &RelayCatalogEntry) -> Result<(), SoftchatError> {
476    canonical_relay_url(&entry.url)?;
477    if entry.created_at < 0
478        || !(-1..=i32::from(u16::MAX)).contains(&entry.dns_priority)
479        || !(-1..=i32::from(u16::MAX)).contains(&entry.dns_weight)
480        || (entry.is_dns_discovered
481            && (entry.dns_priority < 0
482                || entry.dns_weight < 0
483                || entry.dns_expires_at < entry.created_at))
484        || (!entry.is_dns_discovered
485            && (entry.dns_priority != -1 || entry.dns_weight != -1 || entry.dns_expires_at != -1))
486        || (entry.source == RelayCatalogSource::Custom && entry.is_user_excluded)
487    {
488        Err(SoftchatError::InvalidRelayCatalog)
489    } else {
490        Ok(())
491    }
492}
493
494fn validated_rows(
495    entries: Vec<RelayCatalogEntry>,
496) -> Result<BTreeMap<String, RelayCatalogEntry>, SoftchatError> {
497    if entries.len() > MAX_RELAY_CATALOG_ENTRIES {
498        return Err(SoftchatError::InvalidRelayCatalog);
499    }
500    let mut rows = BTreeMap::new();
501    let mut active = 0_u8;
502    for mut entry in entries {
503        validate_entry(&entry)?;
504        entry.url = canonical_relay_url(&entry.url)?;
505        if entry.is_active {
506            active = active
507                .checked_add(1)
508                .ok_or(SoftchatError::InvalidRelayCatalog)?;
509        }
510        if rows.insert(entry.url.clone(), entry).is_some() {
511            return Err(SoftchatError::InvalidRelayCatalog);
512        }
513    }
514    if active > 1 {
515        return Err(SoftchatError::InvalidRelayCatalog);
516    }
517    Ok(rows)
518}
519
520fn ensure_one_active(rows: &mut BTreeMap<String, RelayCatalogEntry>) -> Result<(), SoftchatError> {
521    if rows
522        .values()
523        .any(|entry| entry.is_active && is_effective(entry))
524    {
525        return Ok(());
526    }
527    for entry in rows.values_mut() {
528        entry.is_active = false;
529    }
530    let selected = ordered_effective_entries(rows.values().cloned().collect())?
531        .first()
532        .map(|entry| entry.url.clone())
533        .ok_or(SoftchatError::InvalidRelayCatalog)?;
534    rows.get_mut(&selected)
535        .ok_or(SoftchatError::InvalidRelayCatalog)?
536        .is_active = true;
537    Ok(())
538}
539
540fn ordered_effective_entries(
541    mut entries: Vec<RelayCatalogEntry>,
542) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
543    let mut unique = BTreeSet::new();
544    entries.retain(is_effective);
545    for entry in &entries {
546        validate_entry(entry)?;
547        if !unique.insert(entry.url.clone()) {
548            return Err(SoftchatError::InvalidRelayCatalog);
549        }
550    }
551    entries.sort_by(|left, right| {
552        let left_priority = (left.dns_priority >= 0).then_some(left.dns_priority);
553        let right_priority = (right.dns_priority >= 0).then_some(right.dns_priority);
554        left_priority
555            .is_none()
556            .cmp(&right_priority.is_none())
557            .then_with(|| left_priority.cmp(&right_priority))
558            .then_with(|| left.url.cmp(&right.url))
559    });
560    Ok(entries)
561}
562
563fn entry_storage_order(left: &RelayCatalogEntry, right: &RelayCatalogEntry) -> std::cmp::Ordering {
564    left.url.cmp(&right.url)
565}
566
567fn sorted_rows(rows: BTreeMap<String, RelayCatalogEntry>) -> Vec<RelayCatalogEntry> {
568    rows.into_values().collect()
569}
570
571fn is_effective(entry: &RelayCatalogEntry) -> bool {
572    entry.source == RelayCatalogSource::Custom
573        || (entry.is_dns_discovered && !entry.is_user_excluded)
574}
575
576fn canonical_relay_url(value: &str) -> Result<String, SoftchatError> {
577    Ok(parse_relay_endpoint(value)?.configured_url)
578}
579
580/// Validate one configured relay and split protocol from system transport data.
581///
582/// `wss+noise` and the released legacy `wss#<key>` spelling canonicalize to
583/// one configured URL. Android opens [`RelayEndpointPlan::network_url`] and
584/// gives the 32-byte pin to the SDK Noise handshake.
585///
586/// # Errors
587///
588/// Returns a stable relay-catalog error for insecure URLs, credentials,
589/// missing hosts, or malformed/missing Noise keys.
590pub fn parse_relay_endpoint(value: &str) -> Result<RelayEndpointPlan, SoftchatError> {
591    if value.len() > 2_048 {
592        return Err(SoftchatError::InvalidRelayCatalog);
593    }
594    let configured = Url::parse(value).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
595    if !matches!(configured.scheme(), "wss" | "wss+noise")
596        || !configured.username().is_empty()
597        || configured.password().is_some()
598        || configured.host_str().is_none()
599    {
600        return Err(SoftchatError::InvalidRelayCatalog);
601    }
602    let requires_noise = configured.scheme() == "wss+noise" || configured.fragment().is_some();
603    let noise_remote_static_key = if requires_noise {
604        let encoded = configured
605            .fragment()
606            .filter(|fragment| {
607                fragment.len() == 64 && fragment.bytes().all(|byte| byte.is_ascii_hexdigit())
608            })
609            .ok_or(SoftchatError::InvalidRelayCatalog)?
610            .to_ascii_lowercase();
611        hex::decode(encoded).map_err(|_| SoftchatError::InvalidRelayCatalog)?
612    } else {
613        Vec::new()
614    };
615    let network_value = if configured.scheme() == "wss+noise" {
616        configured.as_str().replacen("wss+noise:", "wss:", 1)
617    } else {
618        configured.to_string()
619    };
620    let mut network = Url::parse(&network_value).map_err(|_| SoftchatError::InvalidRelayCatalog)?;
621    network.set_fragment(None);
622    let configured_url = if requires_noise {
623        let key = hex::encode(&noise_remote_static_key);
624        let mut pinned = network.clone();
625        pinned.set_fragment(Some(&key));
626        pinned.to_string().replacen("wss:", "wss+noise:", 1)
627    } else {
628        network.to_string()
629    };
630    Ok(RelayEndpointPlan {
631        configured_url,
632        network_url: network.to_string(),
633        noise_remote_static_key,
634    })
635}
636
637#[cfg(test)]
638#[allow(clippy::expect_used)]
639mod tests {
640    use super::*;
641
642    fn custom(url: &str, created_at: i64, active: bool) -> RelayCatalogEntry {
643        RelayCatalogEntry {
644            url: url.to_owned(),
645            created_at,
646            is_active: active,
647            source: RelayCatalogSource::Custom,
648            is_dns_discovered: false,
649            is_user_excluded: false,
650            dns_priority: -1,
651            dns_weight: -1,
652            dns_expires_at: -1,
653        }
654    }
655
656    fn dns(target: &str, priority: u32, weight: u32, ttl_seconds: u32) -> DnsRelayRecord {
657        DnsRelayRecord {
658            target: target.to_owned(),
659            priority,
660            weight,
661            ttl_seconds,
662        }
663    }
664
665    #[test]
666    fn reconciles_custom_discovered_excluded_and_fallback_state() {
667        let plan = RelayCatalogReducer::reconcile(
668            vec![custom("wss://custom.example", 10, true)],
669            vec![
670                dns("wss://two.example", 20, 1, 600),
671                dns("wss://one.example", 10, 1, 300),
672            ],
673            100,
674            "wss://fallback.example".to_owned(),
675            true,
676        )
677        .expect("valid catalog");
678        assert!(!plan.used_fallback);
679        assert_eq!(plan.active_relay_url, "wss://custom.example/");
680        assert_eq!(plan.next_refresh_at, 400);
681        assert_eq!(
682            RelayCatalogReducer::failover_candidates(
683                plan.entries.clone(),
684                "wss://one.example".to_owned()
685            )
686            .expect("valid failover"),
687            vec![
688                "wss://two.example/".to_owned(),
689                "wss://custom.example/".to_owned(),
690                "wss://one.example/".to_owned()
691            ]
692        );
693
694        let fallback = RelayCatalogReducer::reconcile(
695            plan.entries,
696            Vec::new(),
697            1_000,
698            "wss://fallback.example".to_owned(),
699            false,
700        )
701        .expect("fallback catalog");
702        assert!(fallback.used_fallback);
703        assert!(
704            fallback
705                .entries
706                .iter()
707                .any(|entry| entry.url == "wss://fallback.example/")
708        );
709    }
710
711    #[test]
712    fn deduplicates_dns_targets_by_priority_weight_then_shorter_ttl() {
713        let plan = RelayCatalogReducer::reconcile(
714            Vec::new(),
715            vec![
716                dns("wss://relay.example", 20, 100, 600),
717                dns("wss://relay.example", 10, 1, 500),
718                dns("wss://relay.example", 10, 2, 700),
719                dns("wss://relay.example", 10, 2, 300),
720            ],
721            100,
722            "wss://fallback.example".to_owned(),
723            true,
724        )
725        .expect("valid catalog");
726        let entry = plan.entries.first().expect("one relay");
727        assert_eq!(entry.dns_priority, 10);
728        assert_eq!(entry.dns_weight, 2);
729        assert_eq!(entry.dns_expires_at, 400);
730    }
731
732    #[test]
733    fn retry_policy_is_bounded_and_rotates_permanent_failures() {
734        assert_eq!(
735            plan_relay_retry(0, RelayFailureKind::Temporary, false),
736            RelayRetryPlan {
737                action: RelayRetryAction::WaitSameRelay,
738                delay_ms: 500,
739                jitter_max_ms: 250,
740            }
741        );
742        assert_eq!(
743            plan_relay_retry(99, RelayFailureKind::RateLimited, false).delay_ms,
744            8_000
745        );
746        assert_eq!(
747            plan_relay_retry(2, RelayFailureKind::Authentication, false),
748            RelayRetryPlan {
749                action: RelayRetryAction::RotateRelay,
750                delay_ms: 0,
751                jitter_max_ms: 250,
752            }
753        );
754        assert_eq!(
755            plan_relay_retry(2, RelayFailureKind::NetworkUnavailable, true).delay_ms,
756            0
757        );
758    }
759
760    #[test]
761    fn canonicalizes_the_released_noise_endpoint_spellings() {
762        let key = "01".repeat(32);
763        let explicit = parse_relay_endpoint(&format!("wss+noise://relay.example/#{key}"))
764            .expect("valid explicit endpoint");
765        let legacy = parse_relay_endpoint(&format!("wss://relay.example/#{key}"))
766            .expect("valid legacy endpoint");
767        assert_eq!(explicit, legacy);
768        assert_eq!(explicit.network_url, "wss://relay.example/");
769        assert_eq!(explicit.noise_remote_static_key, vec![1; 32]);
770        assert!(
771            parse_relay_endpoint("wss+noise://relay.example/").is_err(),
772            "Noise must never silently run without its responder pin"
773        );
774    }
775}