Skip to main content

odp_core/
references.rs

1use std::{net::IpAddr, str::FromStr};
2
3use thiserror::Error;
4use url::Url;
5
6use crate::Operation;
7
8#[derive(Clone, Debug, Error, PartialEq)]
9pub enum ReferenceError {
10    #[error("invalid ODP URL: {0}")]
11    InvalidUrl(String),
12    #[error("ODP URL must include a host")]
13    MissingHost,
14    #[error("ODP URL cannot contain user information")]
15    UserInformation,
16    #[error("ODP URL must use HTTPS except on loopback hosts")]
17    InsecureUrl,
18    #[error(
19        "ODP resource reference must be an origin-relative absolute path or secure absolute URL"
20    )]
21    InvalidReference,
22    #[error("ODP resource reference cannot be scheme-relative")]
23    SchemeRelativeReference,
24    #[error("ODP resource reference cannot contain a fragment")]
25    Fragment,
26    #[error("ODP continuation reference must remain on the Service origin")]
27    CrossOriginContinuation,
28    #[error("ODP endpoint base must be an origin-relative absolute path")]
29    InvalidEndpointBase,
30    #[error("{0:?} requires a valid local resource identifier")]
31    InvalidResourceIdentifier(Operation),
32    #[error("{0:?} does not accept a resource identifier")]
33    UnexpectedResourceIdentifier(Operation),
34}
35
36pub fn is_local_resource_identifier(value: &str) -> bool {
37    !matches!(value, "." | "..")
38        && !value.is_empty()
39        && value.len() <= 128
40        && value
41            .bytes()
42            .all(|byte| byte.is_ascii_alphanumeric() || b"._~-".contains(&byte))
43}
44
45pub const fn operation_method(operation: Operation) -> &'static str {
46    match operation {
47        Operation::SearchCollections | Operation::SearchOfferings => "POST",
48        _ => "GET",
49    }
50}
51
52pub fn derive_service_origin(service_document_url: &str) -> Result<String, ReferenceError> {
53    let url = parse_url(service_document_url)?;
54    require_secure_url(&url)?;
55    Ok(url.origin().ascii_serialization())
56}
57
58pub fn resolve_resource_reference(
59    reference: &str,
60    service_origin: &str,
61) -> Result<Url, ReferenceError> {
62    if reference.starts_with("//") {
63        return Err(ReferenceError::SchemeRelativeReference);
64    }
65    if !reference.starts_with('/')
66        && !reference.starts_with("https://")
67        && !reference.starts_with("http://localhost")
68        && !reference.starts_with("http://127.0.0.1")
69        && !reference.starts_with("http://[::1]")
70    {
71        return Err(ReferenceError::InvalidReference);
72    }
73    let origin = parse_url(service_origin)?;
74    let resolved = origin
75        .join(reference)
76        .map_err(|error| ReferenceError::InvalidUrl(error.to_string()))?;
77    if !resolved.username().is_empty() || resolved.password().is_some() {
78        return Err(ReferenceError::UserInformation);
79    }
80    if resolved.fragment().is_some() {
81        return Err(ReferenceError::Fragment);
82    }
83    require_secure_url(&resolved)?;
84    Ok(resolved)
85}
86
87pub fn resolve_continuation(reference: &str, service_origin: &str) -> Result<Url, ReferenceError> {
88    let resolved = resolve_resource_reference(reference, service_origin)?;
89    if resolved.origin().ascii_serialization() != derive_service_origin(service_origin)? {
90        return Err(ReferenceError::CrossOriginContinuation);
91    }
92    Ok(resolved)
93}
94
95pub fn build_operation_url(
96    endpoint_base: &str,
97    operation: Operation,
98    service_origin: &str,
99    id: Option<&str>,
100) -> Result<Url, ReferenceError> {
101    if !endpoint_base.starts_with('/') || endpoint_base.starts_with("//") {
102        return Err(ReferenceError::InvalidEndpointBase);
103    }
104    let path = operation_path(operation, id)?;
105    resolve_resource_reference(
106        &format!("{}{}", endpoint_base.trim_end_matches('/'), path),
107        service_origin,
108    )
109}
110
111fn operation_path(operation: Operation, id: Option<&str>) -> Result<String, ReferenceError> {
112    let resource = matches!(
113        operation,
114        Operation::GetCollection | Operation::GetOffering | Operation::ListCollectionOfferings
115    );
116    if resource && !id.is_some_and(is_local_resource_identifier) {
117        return Err(ReferenceError::InvalidResourceIdentifier(operation));
118    }
119    if !resource && id.is_some() {
120        return Err(ReferenceError::UnexpectedResourceIdentifier(operation));
121    }
122    let id = id.unwrap_or_default();
123    Ok(match operation {
124        Operation::ListCollections => "/collections".to_owned(),
125        Operation::SearchCollections => "/collections/search".to_owned(),
126        Operation::GetCollection => format!("/collections/{id}"),
127        Operation::ListCollectionOfferings => format!("/collections/{id}/offerings"),
128        Operation::ListOfferings => "/offerings".to_owned(),
129        Operation::SearchOfferings => "/offerings/search".to_owned(),
130        Operation::GetOffering => format!("/offerings/{id}"),
131    })
132}
133
134fn parse_url(value: &str) -> Result<Url, ReferenceError> {
135    let url = Url::parse(value).map_err(|error| ReferenceError::InvalidUrl(error.to_string()))?;
136    if !url.username().is_empty() || url.password().is_some() {
137        return Err(ReferenceError::UserInformation);
138    }
139    Ok(url)
140}
141
142fn require_secure_url(url: &Url) -> Result<(), ReferenceError> {
143    let host = url.host_str().ok_or(ReferenceError::MissingHost)?;
144    let loopback = host.eq_ignore_ascii_case("localhost")
145        || IpAddr::from_str(host).is_ok_and(|address| address.is_loopback());
146    if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
147        return Err(ReferenceError::InsecureUrl);
148    }
149    Ok(())
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn canonicalizes_service_origin() {
158        assert_eq!(
159            derive_service_origin("https://EXAMPLE.com:443/.well-known/odp").unwrap(),
160            "https://example.com"
161        );
162    }
163
164    #[test]
165    fn rejects_cross_origin_continuation() {
166        assert_eq!(
167            resolve_continuation("https://other.example/next", "https://example.com"),
168            Err(ReferenceError::CrossOriginContinuation)
169        );
170    }
171
172    #[test]
173    fn rejects_user_information_in_resource_reference() {
174        assert_eq!(
175            resolve_resource_reference(
176                "https://user@catalog.example/offerings/123",
177                "https://example.com"
178            ),
179            Err(ReferenceError::UserInformation)
180        );
181    }
182
183    #[test]
184    fn builds_fixed_operation_path() {
185        assert_eq!(
186            build_operation_url(
187                "/odp",
188                Operation::GetOffering,
189                "https://example.com",
190                Some("plant-1")
191            )
192            .unwrap()
193            .as_str(),
194            "https://example.com/odp/offerings/plant-1"
195        );
196    }
197}