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}