Skip to main content

sidereon_core/ntrip/
response.rs

1#[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}