sidereon_core/ntrip/
response.rs1#[derive(Clone, Debug, PartialEq, Eq)]
2pub enum NtripRejection {
3 Unauthorized,
4 MountpointNotFound,
5 DigestRequired,
6 CasterError { reason: String },
7 UnexpectedContentType { content_type: String },
8 HttpError { status: u16, reason: String },
9 MalformedHandshake { prefix: Vec<u8> },
10}
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub enum HttpClassification {
14 Stream { chunked: bool },
15 Sourcetable { chunked: bool },
16 Rejection(NtripRejection),
17}
18
19pub fn classify_http_response(
20 status: u16,
21 reason: &str,
22 headers: &[(String, String)],
23) -> HttpClassification {
24 if status == 401 {
25 if digest_required(headers) {
26 return HttpClassification::Rejection(NtripRejection::DigestRequired);
27 }
28 return HttpClassification::Rejection(NtripRejection::Unauthorized);
29 }
30 if status == 404 {
31 return HttpClassification::Rejection(NtripRejection::MountpointNotFound);
32 }
33 if status != 200 {
34 return HttpClassification::Rejection(NtripRejection::HttpError {
35 status,
36 reason: reason.to_string(),
37 });
38 }
39
40 let content_type = header_value(headers, "Content-Type").map(media_type);
41 match content_type.as_deref() {
42 None | Some("gnss/data") => HttpClassification::Stream {
43 chunked: transfer_is_chunked(headers),
44 },
45 Some("gnss/sourcetable") => HttpClassification::Sourcetable {
46 chunked: transfer_is_chunked(headers),
47 },
48 Some(other) => HttpClassification::Rejection(NtripRejection::UnexpectedContentType {
49 content_type: other.to_string(),
50 }),
51 }
52}
53
54pub(crate) fn transfer_is_chunked(headers: &[(String, String)]) -> bool {
55 header_values(headers, "Transfer-Encoding").any(|value| {
56 value
57 .split(',')
58 .any(|token| token.trim().eq_ignore_ascii_case("chunked"))
59 })
60}
61
62fn digest_required(headers: &[(String, String)]) -> bool {
63 let mut saw = false;
64 for value in header_values(headers, "WWW-Authenticate") {
65 for challenge in value.split(',') {
66 let challenge = challenge.trim_start();
67 let Some(scheme) = challenge.split_whitespace().next() else {
68 continue;
69 };
70 if scheme.contains('=') {
71 continue;
72 }
73 saw = true;
74 if !scheme.eq_ignore_ascii_case("digest") {
75 return false;
76 }
77 }
78 }
79 saw
80}
81
82fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
83 header_values(headers, name).next().map(str::to_string)
84}
85
86fn header_values<'a>(
87 headers: &'a [(String, String)],
88 name: &'a str,
89) -> impl Iterator<Item = &'a str> + 'a {
90 headers
91 .iter()
92 .filter(move |(n, _)| n.eq_ignore_ascii_case(name))
93 .map(|(_, v)| v.as_str())
94}
95
96fn media_type(value: String) -> String {
97 value
98 .split(';')
99 .next()
100 .unwrap_or("")
101 .trim()
102 .to_ascii_lowercase()
103}