Skip to main content

sidereon_core/ntrip/
request.rs

1use crate::{Error, Result};
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4pub enum NtripVersion {
5    Rev1,
6    Rev2,
7}
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct NtripCredentials {
11    pub username: String,
12    pub password: String,
13}
14
15#[derive(Clone, Debug, PartialEq)]
16pub struct NtripConfig {
17    pub host: String,
18    pub port: u16,
19    pub mountpoint: String,
20    pub version: NtripVersion,
21    pub credentials: Option<NtripCredentials>,
22    pub user_agent_product: String,
23    pub gga_interval_s: Option<f64>,
24}
25
26impl Default for NtripConfig {
27    fn default() -> Self {
28        Self {
29            host: String::new(),
30            port: 2101,
31            mountpoint: String::new(),
32            version: NtripVersion::Rev2,
33            credentials: None,
34            user_agent_product: format!("sidereon/{}", env!("CARGO_PKG_VERSION")),
35            gga_interval_s: None,
36        }
37    }
38}
39
40impl NtripConfig {
41    pub fn request_bytes(&self) -> Result<Vec<u8>> {
42        let path = self.validated_path()?;
43        let headers = self.common_headers()?;
44        let mut out = Vec::new();
45        match self.version {
46            NtripVersion::Rev1 => {
47                write_line(&mut out, &format!("GET {path} HTTP/1.0"));
48                for (name, value) in headers {
49                    write_line(&mut out, &format!("{name}: {value}"));
50                }
51            }
52            NtripVersion::Rev2 => {
53                write_line(&mut out, &format!("GET {path} HTTP/1.1"));
54                for (name, value) in headers {
55                    write_line(&mut out, &format!("{name}: {value}"));
56                }
57            }
58        }
59        out.extend_from_slice(b"\r\n");
60        Ok(out)
61    }
62
63    pub fn request_headers(&self) -> Result<(String, Vec<(String, String)>)> {
64        if self.version != NtripVersion::Rev2 {
65            return Err(Error::InvalidInput(
66                "request_headers is only defined for NTRIP rev2".into(),
67            ));
68        }
69        Ok((self.validated_path()?, self.common_headers()?))
70    }
71
72    fn validated_path(&self) -> Result<String> {
73        validate_config(self)?;
74        if self.mountpoint.is_empty() {
75            Ok("/".into())
76        } else {
77            Ok(format!("/{}", self.mountpoint))
78        }
79    }
80
81    fn common_headers(&self) -> Result<Vec<(String, String)>> {
82        validate_config(self)?;
83        let mut headers = Vec::new();
84        if self.version == NtripVersion::Rev2 {
85            headers.push(("Host".into(), format!("{}:{}", self.host, self.port)));
86            headers.push(("Ntrip-Version".into(), "Ntrip/2.0".into()));
87        }
88        headers.push((
89            "User-Agent".into(),
90            format!("NTRIP {}", self.user_agent_product),
91        ));
92        if let Some(credentials) = &self.credentials {
93            let token = format!("{}:{}", credentials.username, credentials.password);
94            headers.push((
95                "Authorization".into(),
96                format!("Basic {}", base64(token.as_bytes())),
97            ));
98        }
99        if self.version == NtripVersion::Rev2 {
100            headers.push(("Connection".into(), "close".into()));
101        }
102        Ok(headers)
103    }
104}
105
106fn validate_config(config: &NtripConfig) -> Result<()> {
107    if config.host.bytes().any(|b| b == b'\r' || b == b'\n') {
108        return Err(Error::InvalidInput(
109            "NTRIP host must not contain CR or LF".into(),
110        ));
111    }
112
113    if config
114        .mountpoint
115        .bytes()
116        .any(|b| b.is_ascii_control() || b.is_ascii_whitespace() || b == b'/' || b == b'?')
117    {
118        return Err(Error::InvalidInput(
119            "NTRIP mountpoint contains a forbidden byte".into(),
120        ));
121    }
122
123    let product = &config.user_agent_product;
124    let slash_count = product.bytes().filter(|&b| b == b'/').count();
125    if product.is_empty()
126        || slash_count != 1
127        || product
128            .bytes()
129            .any(|b| b.is_ascii_control() || b.is_ascii_whitespace())
130    {
131        return Err(Error::InvalidInput(
132            "user_agent_product must be name/version with no whitespace".into(),
133        ));
134    }
135
136    if let Some(credentials) = &config.credentials {
137        if credentials.username.contains(':') {
138            return Err(Error::InvalidInput(
139                "NTRIP username must not contain ':'".into(),
140            ));
141        }
142        if credentials
143            .username
144            .bytes()
145            .chain(credentials.password.bytes())
146            .any(|b| b == b'\r' || b == b'\n')
147        {
148            return Err(Error::InvalidInput(
149                "NTRIP credentials must not contain CR or LF".into(),
150            ));
151        }
152    }
153
154    if let Some(interval) = config.gga_interval_s {
155        if !interval.is_finite() || interval <= 0.0 {
156            return Err(Error::InvalidInput(
157                "gga_interval_s must be finite and positive".into(),
158            ));
159        }
160    }
161
162    Ok(())
163}
164
165fn write_line(out: &mut Vec<u8>, line: &str) {
166    out.extend_from_slice(line.as_bytes());
167    out.extend_from_slice(b"\r\n");
168}
169
170fn base64(bytes: &[u8]) -> String {
171    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
172    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
173    for chunk in bytes.chunks(3) {
174        let b0 = chunk[0];
175        let b1 = *chunk.get(1).unwrap_or(&0);
176        let b2 = *chunk.get(2).unwrap_or(&0);
177        let n = ((u32::from(b0)) << 16) | ((u32::from(b1)) << 8) | u32::from(b2);
178        out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
179        out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
180        if chunk.len() > 1 {
181            out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
182        } else {
183            out.push('=');
184        }
185        if chunk.len() > 2 {
186            out.push(ALPHABET[(n & 0x3f) as usize] as char);
187        } else {
188            out.push('=');
189        }
190    }
191    out
192}