sidereon-core 0.13.0

The complete Sidereon engine: numerical astrodynamics propagation core plus the GNSS domain layer (SP3, broadcast ephemeris, multi-GNSS positioning, RTK/PPP, ionosphere/troposphere, DOP) behind a default-on gnss feature
Documentation
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NtripRejection {
    Unauthorized,
    MountpointNotFound,
    DigestRequired,
    CasterError { reason: String },
    UnexpectedContentType { content_type: String },
    HttpError { status: u16, reason: String },
    MalformedHandshake { prefix: Vec<u8> },
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HttpClassification {
    Stream { chunked: bool },
    Sourcetable { chunked: bool },
    Rejection(NtripRejection),
}

pub fn classify_http_response(
    status: u16,
    reason: &str,
    headers: &[(String, String)],
) -> HttpClassification {
    if status == 401 {
        if digest_required(headers) {
            return HttpClassification::Rejection(NtripRejection::DigestRequired);
        }
        return HttpClassification::Rejection(NtripRejection::Unauthorized);
    }
    if status == 404 {
        return HttpClassification::Rejection(NtripRejection::MountpointNotFound);
    }
    if status != 200 {
        return HttpClassification::Rejection(NtripRejection::HttpError {
            status,
            reason: reason.to_string(),
        });
    }

    let content_type = header_value(headers, "Content-Type").map(media_type);
    match content_type.as_deref() {
        None | Some("gnss/data") => HttpClassification::Stream {
            chunked: transfer_is_chunked(headers),
        },
        Some("gnss/sourcetable") => HttpClassification::Sourcetable {
            chunked: transfer_is_chunked(headers),
        },
        Some(other) => HttpClassification::Rejection(NtripRejection::UnexpectedContentType {
            content_type: other.to_string(),
        }),
    }
}

pub(crate) fn transfer_is_chunked(headers: &[(String, String)]) -> bool {
    header_values(headers, "Transfer-Encoding").any(|value| {
        value
            .split(',')
            .any(|token| token.trim().eq_ignore_ascii_case("chunked"))
    })
}

fn digest_required(headers: &[(String, String)]) -> bool {
    let mut saw = false;
    for value in header_values(headers, "WWW-Authenticate") {
        for challenge in value.split(',') {
            let challenge = challenge.trim_start();
            let Some(scheme) = challenge.split_whitespace().next() else {
                continue;
            };
            if scheme.contains('=') {
                continue;
            }
            saw = true;
            if !scheme.eq_ignore_ascii_case("digest") {
                return false;
            }
        }
    }
    saw
}

fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
    header_values(headers, name).next().map(str::to_string)
}

fn header_values<'a>(
    headers: &'a [(String, String)],
    name: &'a str,
) -> impl Iterator<Item = &'a str> + 'a {
    headers
        .iter()
        .filter(move |(n, _)| n.eq_ignore_ascii_case(name))
        .map(|(_, v)| v.as_str())
}

fn media_type(value: String) -> String {
    value
        .split(';')
        .next()
        .unwrap_or("")
        .trim()
        .to_ascii_lowercase()
}