odp-core 0.1.1

Transport-independent protocol primitives for the Offering Discovery Protocol.
Documentation
use std::{net::IpAddr, str::FromStr};

use thiserror::Error;
use url::Url;

use crate::Operation;

#[derive(Clone, Debug, Error, PartialEq)]
pub enum ReferenceError {
    #[error("invalid ODP URL: {0}")]
    InvalidUrl(String),
    #[error("ODP URL must include a host")]
    MissingHost,
    #[error("ODP URL cannot contain user information")]
    UserInformation,
    #[error("ODP URL must use HTTPS except on loopback hosts")]
    InsecureUrl,
    #[error(
        "ODP resource reference must be an origin-relative absolute path or secure absolute URL"
    )]
    InvalidReference,
    #[error("ODP resource reference cannot be scheme-relative")]
    SchemeRelativeReference,
    #[error("ODP resource reference cannot contain a fragment")]
    Fragment,
    #[error("ODP continuation reference must remain on the Service origin")]
    CrossOriginContinuation,
    #[error("ODP endpoint base must be an origin-relative absolute path")]
    InvalidEndpointBase,
    #[error("{0:?} requires a valid local resource identifier")]
    InvalidResourceIdentifier(Operation),
    #[error("{0:?} does not accept a resource identifier")]
    UnexpectedResourceIdentifier(Operation),
}

pub fn is_local_resource_identifier(value: &str) -> bool {
    !matches!(value, "." | "..")
        && !value.is_empty()
        && value.len() <= 128
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || b"._~-".contains(&byte))
}

pub const fn operation_method(operation: Operation) -> &'static str {
    match operation {
        Operation::SearchCollections | Operation::SearchOfferings => "POST",
        _ => "GET",
    }
}

pub fn derive_service_origin(service_document_url: &str) -> Result<String, ReferenceError> {
    let url = parse_url(service_document_url)?;
    require_secure_url(&url)?;
    Ok(url.origin().ascii_serialization())
}

pub fn resolve_resource_reference(
    reference: &str,
    service_origin: &str,
) -> Result<Url, ReferenceError> {
    if reference.starts_with("//") {
        return Err(ReferenceError::SchemeRelativeReference);
    }
    if !reference.starts_with('/')
        && !reference.starts_with("https://")
        && !reference.starts_with("http://localhost")
        && !reference.starts_with("http://127.0.0.1")
        && !reference.starts_with("http://[::1]")
    {
        return Err(ReferenceError::InvalidReference);
    }
    let origin = parse_url(service_origin)?;
    let resolved = origin
        .join(reference)
        .map_err(|error| ReferenceError::InvalidUrl(error.to_string()))?;
    if !resolved.username().is_empty() || resolved.password().is_some() {
        return Err(ReferenceError::UserInformation);
    }
    if resolved.fragment().is_some() {
        return Err(ReferenceError::Fragment);
    }
    require_secure_url(&resolved)?;
    Ok(resolved)
}

pub fn resolve_continuation(reference: &str, service_origin: &str) -> Result<Url, ReferenceError> {
    let resolved = resolve_resource_reference(reference, service_origin)?;
    if resolved.origin().ascii_serialization() != derive_service_origin(service_origin)? {
        return Err(ReferenceError::CrossOriginContinuation);
    }
    Ok(resolved)
}

pub fn build_operation_url(
    endpoint_base: &str,
    operation: Operation,
    service_origin: &str,
    id: Option<&str>,
) -> Result<Url, ReferenceError> {
    if !endpoint_base.starts_with('/') || endpoint_base.starts_with("//") {
        return Err(ReferenceError::InvalidEndpointBase);
    }
    let path = operation_path(operation, id)?;
    resolve_resource_reference(
        &format!("{}{}", endpoint_base.trim_end_matches('/'), path),
        service_origin,
    )
}

fn operation_path(operation: Operation, id: Option<&str>) -> Result<String, ReferenceError> {
    let resource = matches!(
        operation,
        Operation::GetCollection | Operation::GetOffering | Operation::ListCollectionOfferings
    );
    if resource && !id.is_some_and(is_local_resource_identifier) {
        return Err(ReferenceError::InvalidResourceIdentifier(operation));
    }
    if !resource && id.is_some() {
        return Err(ReferenceError::UnexpectedResourceIdentifier(operation));
    }
    let id = id.unwrap_or_default();
    Ok(match operation {
        Operation::ListCollections => "/collections".to_owned(),
        Operation::SearchCollections => "/collections/search".to_owned(),
        Operation::GetCollection => format!("/collections/{id}"),
        Operation::ListCollectionOfferings => format!("/collections/{id}/offerings"),
        Operation::ListOfferings => "/offerings".to_owned(),
        Operation::SearchOfferings => "/offerings/search".to_owned(),
        Operation::GetOffering => format!("/offerings/{id}"),
    })
}

fn parse_url(value: &str) -> Result<Url, ReferenceError> {
    let url = Url::parse(value).map_err(|error| ReferenceError::InvalidUrl(error.to_string()))?;
    if !url.username().is_empty() || url.password().is_some() {
        return Err(ReferenceError::UserInformation);
    }
    Ok(url)
}

fn require_secure_url(url: &Url) -> Result<(), ReferenceError> {
    let host = url.host_str().ok_or(ReferenceError::MissingHost)?;
    let loopback = host.eq_ignore_ascii_case("localhost")
        || IpAddr::from_str(host).is_ok_and(|address| address.is_loopback());
    if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
        return Err(ReferenceError::InsecureUrl);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn canonicalizes_service_origin() {
        assert_eq!(
            derive_service_origin("https://EXAMPLE.com:443/.well-known/odp").unwrap(),
            "https://example.com"
        );
    }

    #[test]
    fn rejects_cross_origin_continuation() {
        assert_eq!(
            resolve_continuation("https://other.example/next", "https://example.com"),
            Err(ReferenceError::CrossOriginContinuation)
        );
    }

    #[test]
    fn rejects_user_information_in_resource_reference() {
        assert_eq!(
            resolve_resource_reference(
                "https://user@catalog.example/offerings/123",
                "https://example.com"
            ),
            Err(ReferenceError::UserInformation)
        );
    }

    #[test]
    fn builds_fixed_operation_path() {
        assert_eq!(
            build_operation_url(
                "/odp",
                Operation::GetOffering,
                "https://example.com",
                Some("plant-1")
            )
            .unwrap()
            .as_str(),
            "https://example.com/odp/offerings/plant-1"
        );
    }
}