umsh_ulcp/
bridge.rs

1//! Bridge client configuration and connection status (device domain).
2pub const HOST_MAX: usize = 253;
3pub const DEFAULT_PORT: u16 = 21837;
4
5#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6#[repr(u8)]
7pub enum State {
8    #[default]
9    Disabled = 0,
10    Unconfigured = 1,
11    WaitingForNetwork = 2,
12    Connecting = 3,
13    Connected = 4,
14    Retrying = 5,
15}
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18#[repr(u8)]
19pub enum Reason {
20    #[default]
21    None = 0,
22    Dns = 1,
23    Tcp = 2,
24    Tls = 3,
25    Authentication = 4,
26    IdleTimeout = 5,
27    IdentityUnavailable = 6,
28}
29
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub struct Link {
32    pub state: State,
33    pub reason: Reason,
34}
35
36impl Link {
37    pub const fn new(state: State, reason: Reason) -> Self {
38        Self { state, reason }
39    }
40    pub const fn encode(self) -> [u8; 2] {
41        [self.state as u8, self.reason as u8]
42    }
43    pub fn decode(bytes: &[u8]) -> Option<Self> {
44        let [state, reason] = bytes else {
45            return None;
46        };
47        Some(Self {
48            state: match state {
49                0 => State::Disabled,
50                1 => State::Unconfigured,
51                2 => State::WaitingForNetwork,
52                3 => State::Connecting,
53                4 => State::Connected,
54                5 => State::Retrying,
55                _ => return None,
56            },
57            reason: match reason {
58                0 => Reason::None,
59                1 => Reason::Dns,
60                2 => Reason::Tcp,
61                3 => Reason::Tls,
62                4 => Reason::Authentication,
63                5 => Reason::IdleTimeout,
64                6 => Reason::IdentityUnavailable,
65                _ => return None,
66            },
67        })
68    }
69}
70
71/// DNS A-label hostname or unbracketed IP literal; empty clears the endpoint.
72pub fn valid_host(host: &str) -> bool {
73    if host.is_empty() {
74        return true;
75    }
76    if host.len() > HOST_MAX || !host.is_ascii() {
77        return false;
78    }
79    if host.contains(':') {
80        return host.parse::<core::net::Ipv6Addr>().is_ok();
81    }
82    if host.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
83        return host.parse::<core::net::Ipv4Addr>().is_ok();
84    }
85    host.strip_suffix('.')
86        .unwrap_or(host)
87        .split('.')
88        .all(|label| {
89            !label.is_empty()
90                && label.len() <= 63
91                && label.as_bytes()[0].is_ascii_alphanumeric()
92                && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
93                && label
94                    .bytes()
95                    .all(|b| b.is_ascii_alphanumeric() || b == b'-')
96        })
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    #[test]
103    fn hosts_are_endpoints_not_urls() {
104        for host in [
105            "",
106            "bridge.example.net",
107            "bridge.example.net.",
108            "127.0.0.1",
109            "::1",
110            "2001:db8::1",
111        ] {
112            assert!(valid_host(host), "{host}");
113        }
114        for host in [
115            "https://bridge",
116            "bridge:21837",
117            "[::1]",
118            "a..b",
119            "-a",
120            "a-",
121            "999.0.0.1",
122            "a\0b",
123            "b ridge",
124        ] {
125            assert!(!valid_host(host), "{host}");
126        }
127    }
128    #[test]
129    fn status_round_trips_and_rejects_unknown_codes() {
130        let link = Link::new(State::Retrying, Reason::Authentication);
131        assert_eq!(Link::decode(&link.encode()), Some(link));
132        for bytes in [&[][..], &[0], &[6, 0], &[0, 7], &[0, 0, 0]] {
133            assert!(Link::decode(bytes).is_none());
134        }
135    }
136}