Skip to main content

iroh_http_discovery/engine/
types.rs

1use std::net::{IpAddr, SocketAddr};
2
3use super::TransportError;
4
5/// Transport protocol component of a DNS-SD service type.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum Protocol {
8    /// UDP-based service (`_udp`).
9    #[default]
10    Udp,
11    /// TCP-based service (`_tcp`).
12    Tcp,
13}
14
15/// Canonical description of a DNS-SD advertisement.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ServiceConfig {
18    /// Bare service label, without the leading underscore or protocol/domain.
19    pub service_name: String,
20    /// Human-visible DNS-SD instance label.
21    pub instance_name: String,
22    /// SRV port.
23    pub port: u16,
24    /// Explicit addresses in addition to addresses supplied by the transport.
25    pub addrs: Vec<IpAddr>,
26    /// TXT properties. Order is retained across the transport boundary.
27    pub txt: Vec<(String, String)>,
28    /// DNS-SD transport protocol.
29    pub protocol: Protocol,
30}
31
32/// Canonical description of a DNS-SD browse.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct BrowseConfig {
35    /// Bare service label, without the leading underscore or protocol/domain.
36    pub service_name: String,
37    /// DNS-SD transport protocol.
38    pub protocol: Protocol,
39}
40
41impl BrowseConfig {
42    /// Create a UDP browse configuration.
43    pub fn udp(service_name: impl Into<String>) -> Self {
44        Self {
45            service_name: service_name.into(),
46            protocol: Protocol::Udp,
47        }
48    }
49}
50
51impl Protocol {
52    fn label(self) -> &'static str {
53        match self {
54            Self::Udp => "_udp",
55            Self::Tcp => "_tcp",
56        }
57    }
58}
59
60/// Validate a bare service name and return its fully qualified local type.
61pub fn service_type(service_name: &str, protocol: Protocol) -> Result<String, TransportError> {
62    if service_name.is_empty() {
63        return Err(TransportError::new("service name must not be empty"));
64    }
65    if !service_name
66        .chars()
67        .all(|character| character.is_ascii_alphanumeric() || character == '-')
68    {
69        return Err(TransportError::new(format!(
70            "{service_name:?} must contain only ASCII letters, digits, and '-'"
71        )));
72    }
73    Ok(format!("_{service_name}.{}.local.", protocol.label()))
74}
75
76/// A fully resolved, active DNS-SD service.
77///
78/// Removal is represented separately by [`RawEvent::Remove`]. This keeps a
79/// tombstone from pretending to be a resolved record with zero/empty fields.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct ServiceRecord {
82    /// `true` for an appeared/updated service and `false` for a tombstone.
83    pub is_active: bool,
84    /// Fully qualified service type and domain, for example
85    /// `_my-app._udp.local.`.
86    pub service_type: String,
87    /// DNS-SD instance label.
88    pub instance_name: String,
89    /// Resolved host name, when supplied by the platform.
90    pub host: Option<String>,
91    /// Resolved SRV port.
92    pub port: u16,
93    /// Resolved socket addresses.
94    pub addrs: Vec<SocketAddr>,
95    /// All TXT key/value properties in transport order.
96    pub txt: Vec<(String, String)>,
97}
98
99/// Lossless event emitted by a platform DNS-SD transport.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum RawEvent {
102    /// A service appeared or its resolved data changed.
103    Upsert(ServiceRecord),
104    /// A service disappeared.
105    Remove {
106        /// Fully qualified service type and domain.
107        service_type: String,
108        /// DNS-SD instance label.
109        instance_name: String,
110    },
111}
112
113impl RawEvent {
114    /// Identity used to correlate an upsert and remove event.
115    pub fn identity(&self) -> (&str, &str) {
116        match self {
117            Self::Upsert(record) => (&record.service_type, &record.instance_name),
118            Self::Remove {
119                service_type,
120                instance_name,
121            } => (service_type, instance_name),
122        }
123    }
124}
125
126impl From<RawEvent> for ServiceRecord {
127    fn from(event: RawEvent) -> Self {
128        match event {
129            RawEvent::Upsert(mut record) => {
130                record.is_active = true;
131                record
132            }
133            RawEvent::Remove {
134                service_type,
135                instance_name,
136            } => Self {
137                is_active: false,
138                service_type,
139                instance_name,
140                host: None,
141                port: 0,
142                addrs: Vec::new(),
143                txt: Vec::new(),
144            },
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn upsert_and_remove_have_the_same_explicit_identity() {
155        let record = ServiceRecord {
156            is_active: true,
157            service_type: "_web._tcp.local.".into(),
158            instance_name: "docs".into(),
159            host: Some("docs.local.".into()),
160            port: 443,
161            addrs: vec![],
162            txt: vec![("path".into(), "/manual".into())],
163        };
164        let upsert = RawEvent::Upsert(record);
165        let remove = RawEvent::Remove {
166            service_type: "_web._tcp.local.".into(),
167            instance_name: "docs".into(),
168        };
169
170        assert_eq!(upsert.identity(), remove.identity());
171    }
172
173    #[test]
174    fn remove_converts_to_the_public_compatibility_tombstone() {
175        let record = ServiceRecord::from(RawEvent::Remove {
176            service_type: "_web._tcp.local.".into(),
177            instance_name: "docs".into(),
178        });
179
180        assert!(!record.is_active);
181        assert_eq!(record.service_type, "_web._tcp.local.");
182        assert_eq!(record.instance_name, "docs");
183        assert_eq!(record.host, None);
184        assert_eq!(record.port, 0);
185        assert!(record.addrs.is_empty());
186        assert!(record.txt.is_empty());
187    }
188
189    #[test]
190    fn service_type_preserves_existing_validation_and_protocol_mapping() {
191        assert!(matches!(
192            service_type("iroh-http", Protocol::Udp),
193            Ok(value) if value == "_iroh-http._udp.local."
194        ));
195        assert!(matches!(
196            service_type("web", Protocol::Tcp),
197            Ok(value) if value == "_web._tcp.local."
198        ));
199        assert!(service_type("bad.name", Protocol::Udp).is_err());
200    }
201}