Skip to main content

appcore_sync/sync/
discovery.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: discovery.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/06/05 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/06/05 00:00:00 by dnettoRaw
8//      ###########      S: 0.6.0
9// =============================================================================
10
11//! DNS peer discovery helpers for conservative sync push.
12
13use crate::sync::error::{SyncError, SyncResult};
14use std::collections::BTreeSet;
15use std::net::ToSocketAddrs;
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
18/// Parsed and normalized address of a synchronization peer.
19pub struct SyncPeerAddress {
20    /// Canonical URL including scheme, host, and port.
21    pub url: String,
22    /// DNS name or IP literal without brackets.
23    pub host: String,
24    /// TCP port used by the synchronization endpoint.
25    pub port: u16,
26    /// HTTP transport scheme.
27    pub scheme: SyncPeerScheme,
28}
29
30impl SyncPeerAddress {
31    /// Parses a peer URL or explicit host-and-port seed.
32    pub fn parse(peer: &str) -> SyncResult<Self> {
33        let parsed = parse_peer_seed(peer, None)?;
34        Ok(Self::from_host_port(
35            parsed.host,
36            parsed.port,
37            parsed.scheme,
38        ))
39    }
40
41    fn from_host_port(host: String, port: u16, scheme: SyncPeerScheme) -> Self {
42        let scheme_text = scheme.as_str();
43        let url = if host.contains(':') {
44            format!("{scheme_text}://[{host}]:{port}")
45        } else {
46            format!("{scheme_text}://{host}:{port}")
47        };
48        Self {
49            url,
50            host,
51            port,
52            scheme,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
58/// HTTP scheme supported by the built-in synchronization transport.
59pub enum SyncPeerScheme {
60    /// Plain HTTP, intended for trusted local or externally secured networks.
61    Http,
62    /// HTTPS with server certificate validation.
63    Https,
64}
65
66impl SyncPeerScheme {
67    /// Returns the lowercase URI scheme.
68    pub fn as_str(self) -> &'static str {
69        match self {
70            SyncPeerScheme::Http => "http",
71            SyncPeerScheme::Https => "https",
72        }
73    }
74}
75
76/// Resolves DNS peer seeds and returns normalized, de-duplicated addresses.
77pub fn discover_dns_sync_peers(
78    seeds: &[String],
79    default_port: u16,
80) -> SyncResult<Vec<SyncPeerAddress>> {
81    let mut peers = BTreeSet::new();
82    for seed in seeds {
83        let parsed = parse_peer_seed(seed, Some(default_port))?;
84        ensure_resolves(&parsed.host, parsed.port)?;
85        let _ = peers.insert(SyncPeerAddress::from_host_port(
86            parsed.host,
87            parsed.port,
88            parsed.scheme,
89        ));
90    }
91    Ok(peers.into_iter().collect())
92}
93
94fn ensure_resolves(host: &str, port: u16) -> SyncResult<()> {
95    let addrs = (host, port)
96        .to_socket_addrs()
97        .map_err(|err| SyncError::DnsResolutionFailed(err.to_string()))?
98        .collect::<Vec<_>>();
99    if addrs.is_empty() {
100        return Err(SyncError::DnsResolutionFailed(
101            "sync dns seed resolved to no addresses".to_string(),
102        ));
103    }
104    Ok(())
105}
106
107struct ParsedPeerSeed {
108    host: String,
109    port: u16,
110    scheme: SyncPeerScheme,
111}
112
113fn parse_peer_seed(peer: &str, default_port: Option<u16>) -> SyncResult<ParsedPeerSeed> {
114    let (scheme, address) = split_scheme(peer)?;
115    if address.is_empty() {
116        return Err(SyncError::InvalidPeerAddress);
117    }
118    if let Some((host, port)) = parse_bracketed_ipv6(address)? {
119        return Ok(ParsedPeerSeed { host, port, scheme });
120    }
121    let (host, port) = parse_plain_host_port(address, default_port)?;
122    Ok(ParsedPeerSeed { host, port, scheme })
123}
124
125fn split_scheme(peer: &str) -> SyncResult<(SyncPeerScheme, &str)> {
126    let peer = peer.trim().trim_end_matches('/');
127    if let Some(address) = peer.strip_prefix("http://") {
128        return Ok((SyncPeerScheme::Http, address));
129    }
130    if let Some(address) = peer.strip_prefix("https://") {
131        return Ok((SyncPeerScheme::Https, address));
132    }
133    if peer.contains("://") {
134        return Err(SyncError::UnsupportedPeerScheme);
135    }
136    Ok((SyncPeerScheme::Http, peer))
137}
138
139fn parse_bracketed_ipv6(peer: &str) -> SyncResult<Option<(String, u16)>> {
140    if !peer.starts_with('[') {
141        return Ok(None);
142    }
143    let Some((host, rest)) = peer[1..].split_once(']') else {
144        return Err(SyncError::InvalidPeerAddress);
145    };
146    let Some(port) = rest.strip_prefix(':') else {
147        return Err(SyncError::InvalidPeerAddress);
148    };
149    Ok(Some((host.to_string(), parse_port(port)?)))
150}
151
152fn parse_plain_host_port(peer: &str, default_port: Option<u16>) -> SyncResult<(String, u16)> {
153    let mut parts = peer.rsplitn(2, ':');
154    let last = parts.next().unwrap_or_default();
155    let host = parts.next();
156    match (host, default_port) {
157        (Some(host), _) if !host.is_empty() => Ok((host.to_string(), parse_port(last)?)),
158        (None, Some(port)) => Ok((last.to_string(), port)),
159        _ => Err(SyncError::InvalidPeerAddress),
160    }
161}
162
163fn parse_port(raw: &str) -> SyncResult<u16> {
164    raw.parse::<u16>()
165        .map_err(|_| SyncError::InvalidPeerAddress)
166}
167
168#[cfg(test)]
169#[path = "discovery_tests.rs"]
170mod tests;