sidereon_core/ntrip/
request.rs1use 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}