webfinger_rs/types/resource.rs
1use std::borrow::Borrow;
2use std::cmp::Ordering;
3use std::fmt;
4use std::hash::{Hash, Hasher};
5use std::str::FromStr;
6
7use http::Uri;
8
9/// Errors that can occur while parsing a WebFinger resource URI.
10#[non_exhaustive]
11#[derive(Debug, thiserror::Error)]
12pub enum ResourceError {
13 /// The resource is a relative reference instead of an absolute URI.
14 #[error("resource must be an absolute URI")]
15 RelativeReference,
16
17 /// The resource contains raw text outside the URI character set.
18 ///
19 /// Resource URI text must be ASCII and every byte must be allowed by RFC 3986 as an
20 /// `unreserved`, `reserved`, or percent-escape marker byte. Characters outside that set, such
21 /// as `{`, `|`, `^`, and non-ASCII code points, must be percent-encoded before parsing.
22 #[error("resource contains invalid URI characters")]
23 InvalidCharacters,
24
25 /// The resource contains a malformed percent escape.
26 #[error("resource contains invalid percent encoding")]
27 InvalidPercentEncoding,
28
29 /// The resource is an invalid HTTP or HTTPS URI.
30 #[error(transparent)]
31 InvalidHttpUri(#[from] http::uri::InvalidUri),
32
33 /// The resource is an HTTP or HTTPS URI without an authority.
34 #[error("HTTP and HTTPS resources must include an authority")]
35 MissingHttpAuthority,
36}
37
38// `http::uri::InvalidUri` does not implement `PartialEq` or `Eq`, so this cannot be derived.
39// See https://github.com/hyperium/http/issues/849.
40impl PartialEq for ResourceError {
41 fn eq(&self, other: &Self) -> bool {
42 match (self, other) {
43 (Self::RelativeReference, Self::RelativeReference)
44 | (Self::InvalidCharacters, Self::InvalidCharacters)
45 | (Self::InvalidPercentEncoding, Self::InvalidPercentEncoding)
46 | (Self::MissingHttpAuthority, Self::MissingHttpAuthority) => true,
47 (Self::InvalidHttpUri(left), Self::InvalidHttpUri(right)) => {
48 left.to_string() == right.to_string()
49 }
50 _ => false,
51 }
52 }
53}
54
55impl Eq for ResourceError {}
56
57/// A WebFinger resource URI.
58///
59/// RFC 7033 uses the `resource` query parameter for the query target, which is a URI rather than a
60/// relative reference. `Resource` stores that URI text after checking the URI syntax that this crate
61/// relies on at request boundaries.
62///
63/// Validation is intentionally conservative:
64///
65/// - the value must start with an RFC 3986 URI scheme;
66/// - the value must contain only raw RFC 3986 URI characters;
67/// - every `%` must start a complete percent escape;
68/// - raw non-ASCII text must already be percent-encoded; and
69/// - `http` and `https` resources must use the `//authority` form before their host is exposed
70/// through [`Resource::host`].
71///
72/// Common valid resources include `acct:carol@example.com` and
73/// `https://example.org/users/carol`.
74///
75/// # Examples
76///
77/// Parse a valid `acct:` resource:
78///
79/// ```rust
80/// use webfinger_rs::Resource;
81///
82/// let resource = "acct:carol@example.com".parse::<Resource>()?;
83/// assert_eq!(resource.as_str(), "acct:carol@example.com");
84/// # Ok::<(), webfinger_rs::ResourceError>(())
85/// ```
86///
87/// Raw characters outside the URI character set are rejected. Percent-encode them inside the
88/// resource URI before putting that URI in the outer WebFinger query string:
89///
90/// ```rust
91/// use webfinger_rs::{Resource, ResourceError};
92///
93/// let error = "acct:carol{admin}@example.com"
94/// .parse::<Resource>()
95/// .unwrap_err();
96/// assert!(matches!(error, ResourceError::InvalidCharacters));
97///
98/// let resource = "acct:carol%7Badmin%7D@example.com".parse::<Resource>()?;
99/// assert_eq!(resource.as_str(), "acct:carol%7Badmin%7D@example.com");
100/// # Ok::<(), webfinger_rs::ResourceError>(())
101/// ```
102///
103/// HTTP(S) resources must include an authority so host inference cannot treat opaque URI text as a
104/// host:
105///
106/// ```rust
107/// use webfinger_rs::{Resource, ResourceError};
108///
109/// let error = "https:example.org/profile"
110/// .parse::<Resource>()
111/// .unwrap_err();
112/// assert!(matches!(error, ResourceError::MissingHttpAuthority));
113///
114/// let resource = "https://example.org/profile".parse::<Resource>()?;
115/// assert_eq!(resource.host(), Some("example.org"));
116/// # Ok::<(), webfinger_rs::ResourceError>(())
117/// ```
118///
119/// See [RFC 7033 section 4.1] for the `resource` parameter, [RFC 3986 section 2.1] for percent
120/// encoding, [RFC 3986 section 2.2] for reserved characters, [RFC 3986 section 2.3] for
121/// unreserved characters, [RFC 3986 section 3.1] for URI schemes, and [RFC 3986 section 3.2] for
122/// authority.
123///
124/// [RFC 7033 section 4.1]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1
125/// [RFC 3986 section 2.1]: https://www.rfc-editor.org/rfc/rfc3986.html#section-2.1
126/// [RFC 3986 section 2.2]: https://www.rfc-editor.org/rfc/rfc3986.html#section-2.2
127/// [RFC 3986 section 2.3]: https://www.rfc-editor.org/rfc/rfc3986.html#section-2.3
128/// [RFC 3986 section 3.1]: https://www.rfc-editor.org/rfc/rfc3986.html#section-3.1
129/// [RFC 3986 section 3.2]: https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2
130#[derive(Debug, Clone)]
131pub struct Resource {
132 text: String,
133 host: Option<String>,
134}
135
136impl Resource {
137 /// Returns the resource URI as a string slice.
138 pub fn as_str(&self) -> &str {
139 &self.text
140 }
141
142 /// Returns the resource as an [`http::Uri`] when it fits that representation.
143 ///
144 /// WebFinger resources can use schemes such as `acct:` that are valid URI strings but do not
145 /// expose a host through [`http::Uri`]. This accessor is mainly useful for hierarchical
146 /// resources such as `https://example.org/users/carol`.
147 pub fn uri(&self) -> Option<Uri> {
148 Uri::try_from(self.as_str()).ok()
149 }
150
151 /// Returns the host from the resource's [`http::Uri`] representation, when present.
152 ///
153 /// URI schemes such as `acct:` do not have a host in [`http::Uri`], so this returns `None` for
154 /// those resources.
155 pub fn host(&self) -> Option<&str> {
156 self.host.as_deref()
157 }
158}
159
160impl fmt::Display for Resource {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 f.write_str(&self.text)
163 }
164}
165
166/// Resource identity is the URI text.
167///
168/// The `host` field is a construction-time cache derived from `text`, so including it here would
169/// be redundant for `Resource` comparisons and incompatible with borrowed `str` lookup through
170/// [`Borrow`].
171impl PartialEq for Resource {
172 fn eq(&self, other: &Self) -> bool {
173 self.text == other.text
174 }
175}
176
177/// Equality is complete when URI text matches.
178///
179/// `host` is derived from `text`, so it cannot distinguish two otherwise equal resources.
180impl Eq for Resource {}
181
182/// Partial ordering follows URI text only.
183///
184/// This keeps ordering consistent with equality and avoids treating the cached `host` as part of
185/// resource identity.
186impl PartialOrd for Resource {
187 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
188 Some(self.cmp(other))
189 }
190}
191
192/// Ordering follows URI text only.
193///
194/// The cached `host` value is intentionally excluded so `Resource` sorts the same way as its
195/// borrowed string form.
196impl Ord for Resource {
197 fn cmp(&self, other: &Self) -> Ordering {
198 self.text.cmp(&other.text)
199 }
200}
201
202/// Hashing uses URI text only.
203///
204/// This matches [`Borrow<str>`] lookup expectations for hash collections; hashing the cached
205/// `host` would make `Resource` keys hash differently from their borrowed `str` form.
206impl Hash for Resource {
207 fn hash<H: Hasher>(&self, state: &mut H) {
208 self.text.hash(state);
209 }
210}
211
212impl AsRef<str> for Resource {
213 fn as_ref(&self) -> &str {
214 self.as_str()
215 }
216}
217
218impl Borrow<str> for Resource {
219 fn borrow(&self) -> &str {
220 self.as_str()
221 }
222}
223
224impl FromStr for Resource {
225 type Err = ResourceError;
226
227 fn from_str(resource: &str) -> Result<Self, Self::Err> {
228 let host = validate_resource(resource)?;
229 Ok(Self {
230 text: resource.to_string(),
231 host,
232 })
233 }
234}
235
236impl TryFrom<String> for Resource {
237 type Error = ResourceError;
238
239 fn try_from(resource: String) -> Result<Self, Self::Error> {
240 let host = validate_resource(&resource)?;
241 Ok(Self {
242 text: resource,
243 host,
244 })
245 }
246}
247
248impl TryFrom<&str> for Resource {
249 type Error = ResourceError;
250
251 fn try_from(resource: &str) -> Result<Self, Self::Error> {
252 resource.parse()
253 }
254}
255
256fn validate_resource(resource: &str) -> Result<Option<String>, ResourceError> {
257 let Some(scheme) = scheme(resource) else {
258 return Err(ResourceError::RelativeReference);
259 };
260 if !resource.is_ascii() {
261 return Err(ResourceError::InvalidCharacters);
262 }
263 validate_uri_characters(resource)?;
264 validate_percent_escapes(resource)?;
265 if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") {
266 // WebFinger only needs host inference for hierarchical HTTP(S) resources. RFC 3986
267 // section 3.2 attaches an authority to URIs that begin their hier-part with `//`; opaque
268 // forms like `http:foo` must not produce a synthetic host.
269 if !resource[scheme.len()..].starts_with("://") {
270 return Err(ResourceError::MissingHttpAuthority);
271 }
272 let uri = Uri::try_from(resource).map_err(ResourceError::InvalidHttpUri)?;
273 let Some(host) = uri.host() else {
274 return Err(ResourceError::MissingHttpAuthority);
275 };
276 return Ok(Some(host.to_string()));
277 }
278 Ok(None)
279}
280
281fn validate_percent_escapes(resource: &str) -> Result<(), ResourceError> {
282 let mut bytes = resource.as_bytes().iter();
283 while let Some(byte) = bytes.next() {
284 if *byte != b'%' {
285 continue;
286 }
287 let Some(high) = bytes.next() else {
288 return Err(ResourceError::InvalidPercentEncoding);
289 };
290 let Some(low) = bytes.next() else {
291 return Err(ResourceError::InvalidPercentEncoding);
292 };
293 if !high.is_ascii_hexdigit() || !low.is_ascii_hexdigit() {
294 return Err(ResourceError::InvalidPercentEncoding);
295 }
296 }
297 Ok(())
298}
299
300fn validate_uri_characters(resource: &str) -> Result<(), ResourceError> {
301 if resource.bytes().all(is_uri_character) {
302 Ok(())
303 } else {
304 Err(ResourceError::InvalidCharacters)
305 }
306}
307
308fn is_uri_character(byte: u8) -> bool {
309 matches!(
310 byte,
311 b'A'..=b'Z'
312 | b'a'..=b'z'
313 | b'0'..=b'9'
314 | b'-'
315 | b'.'
316 | b'_'
317 | b'~'
318 | b':'
319 | b'/'
320 | b'?'
321 | b'#'
322 | b'['
323 | b']'
324 | b'@'
325 | b'!'
326 | b'$'
327 | b'&'
328 | b'\''
329 | b'('
330 | b')'
331 | b'*'
332 | b'+'
333 | b','
334 | b';'
335 | b'='
336 | b'%'
337 )
338}
339
340fn scheme(resource: &str) -> Option<&str> {
341 let mut bytes = resource.bytes();
342 let first = bytes.next()?;
343 if !first.is_ascii_alphabetic() {
344 return None;
345 }
346
347 for (index, byte) in bytes.enumerate() {
348 match byte {
349 b':' => return Some(&resource[..index + 1]),
350 b'/' | b'?' | b'#' => return None,
351 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'-' | b'.' => {}
352 _ => return None,
353 }
354 }
355 None
356}
357
358#[cfg(test)]
359mod tests {
360 use std::collections::{HashMap, HashSet};
361
362 use super::*;
363
364 /// Accepts `acct:` resources because they are absolute URIs with a scheme.
365 #[test]
366 fn accepts_acct_resource() {
367 let resource = "acct:carol@example.com".parse::<Resource>().unwrap();
368
369 assert_eq!(resource.as_str(), "acct:carol@example.com");
370 }
371
372 /// Accepts hierarchical HTTPS resources with an authority.
373 ///
374 /// Host extraction is used by the CLI fallback path, so this test covers both the original
375 /// resource text and the derived host/URI accessors.
376 #[test]
377 fn accepts_https_resource() {
378 let resource = "https://example.org/users/carol"
379 .parse::<Resource>()
380 .unwrap();
381
382 assert_eq!(resource.as_str(), "https://example.org/users/carol");
383 assert_eq!(resource.host(), Some("example.org"));
384 assert_eq!(
385 resource.uri().map(|uri| uri.to_string()),
386 Some("https://example.org/users/carol".to_string()),
387 );
388 }
389
390 /// Resource identity is the resource URI text.
391 ///
392 /// Borrowed `str` lookup in hash collections must use the same equality and hash inputs as
393 /// owned `Resource` values. This keeps `Borrow<str>` compatible with `Eq` and `Hash` even for
394 /// hierarchical resources whose host is cached during construction.
395 #[test]
396 fn borrowed_str_lookup_uses_resource_text_identity() {
397 let resource = "https://example.org/users/carol"
398 .parse::<Resource>()
399 .unwrap();
400 let mut set = HashSet::new();
401 set.insert(resource.clone());
402
403 let mut map = HashMap::new();
404 map.insert(resource, "profile");
405
406 assert!(set.contains("https://example.org/users/carol"));
407 assert_eq!(map.get("https://example.org/users/carol"), Some(&"profile"));
408 assert!(!set.contains("https://example.org"));
409 assert_eq!(map.get("https://example.org"), None);
410 }
411
412 /// Caches host text during construction without making host part of identity.
413 #[test]
414 fn caches_http_resource_host_at_construction() {
415 for (resource, host) in [
416 ("https://example.org/users/carol", "example.org"),
417 ("https://example.org:8443/users/carol", "example.org"),
418 ("https://user:pass@example.org/users/carol", "example.org"),
419 ("https://[::1]:8443/users/carol", "[::1]"),
420 ] {
421 let resource = resource.parse::<Resource>().unwrap();
422
423 assert_eq!(resource.host(), Some(host));
424 }
425 }
426
427 /// Accepts owned resource text through the same validation path as parsed `&str` input.
428 ///
429 /// The owned conversion preserves the original text because downstream request encoding should
430 /// not normalize or otherwise rewrite caller-provided resource URIs.
431 #[test]
432 fn try_from_string_preserves_resource_text() {
433 let resource = Resource::try_from("acct:carol@example.com".to_string()).unwrap();
434
435 assert_eq!(resource.as_str(), "acct:carol@example.com");
436 assert_eq!(resource.to_string(), "acct:carol@example.com");
437 }
438
439 /// Accepts scheme-specific opaque-looking URIs.
440 ///
441 /// RFC 3986's `URI` production requires a scheme but allows a scheme-specific path without an
442 /// authority. WebFinger commonly uses this shape for `acct:` resources.
443 #[test]
444 fn accepts_scheme_specific_resource() {
445 let resource = "urn:example:animal:ferret:nose"
446 .parse::<Resource>()
447 .unwrap();
448
449 assert_eq!(resource.as_str(), "urn:example:animal:ferret:nose");
450 }
451
452 /// Rejects relative references that `http::Uri` can otherwise parse.
453 ///
454 /// RFC 7033 section 4.1 defines `resource` as a URI identifying the target resource. RFC 3986
455 /// section 4.2 relative references are not enough because they have no standalone scheme.
456 ///
457 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1>.
458 /// See <https://www.rfc-editor.org/rfc/rfc3986.html#section-4.2>.
459 #[test]
460 fn rejects_relative_resource_references() {
461 for resource in [
462 "carol",
463 "/relative",
464 "?resource=acct:carol@example.com",
465 "#fragment",
466 "../x",
467 "",
468 "1acct:carol@example.com",
469 "ac_ct:carol@example.org",
470 ] {
471 let error = resource.parse::<Resource>().unwrap_err();
472
473 assert_eq!(error, ResourceError::RelativeReference);
474 }
475 }
476
477 /// Rejects raw non-ASCII resource text.
478 ///
479 /// RFC 3986 URI syntax is ASCII. Non-ASCII data must be percent-encoded inside the resource URI
480 /// itself before it is put into the WebFinger query parameter.
481 #[test]
482 fn rejects_non_ascii_resource_text() {
483 let error = "acct:carolé@example.org".parse::<Resource>().unwrap_err();
484
485 assert_eq!(error, ResourceError::InvalidCharacters);
486 }
487
488 /// Rejects raw ASCII characters outside the RFC 3986 URI character set.
489 #[test]
490 fn rejects_invalid_raw_uri_characters() {
491 for resource in [
492 "acct:carol{bad}@example.org",
493 "acct:carol|bad@example.org",
494 "acct:carol^bad@example.org",
495 "acct:carol`bad@example.org",
496 ] {
497 let error = resource.parse::<Resource>().unwrap_err();
498
499 assert_eq!(error, ResourceError::InvalidCharacters);
500 }
501 }
502
503 /// Accepts characters outside the raw URI character set when they are percent-encoded.
504 #[test]
505 fn accepts_percent_encoded_invalid_raw_characters() {
506 let resource = "acct:carol%7Bbad%7D@example.org"
507 .parse::<Resource>()
508 .unwrap();
509
510 assert_eq!(resource.as_str(), "acct:carol%7Bbad%7D@example.org");
511 }
512
513 /// Rejects malformed percent escape syntax inside resource URIs.
514 ///
515 /// Percent escapes belong to the resource URI itself after the outer WebFinger query has been
516 /// decoded, so malformed escapes must be rejected at the resource boundary too.
517 #[test]
518 fn rejects_malformed_resource_percent_escape() {
519 for resource in [
520 "acct:carol%GG@example.org",
521 "acct:carol%@example.org",
522 "acct:carol%4@example.org",
523 ] {
524 let error = resource.parse::<Resource>().unwrap_err();
525
526 assert_eq!(error, ResourceError::InvalidPercentEncoding);
527 }
528 }
529
530 /// Rejects HTTP and HTTPS resources that omit the required authority.
531 #[test]
532 fn rejects_http_resources_without_authority() {
533 for resource in ["http:foo", "https:foo", "http:/example.org/path"] {
534 let error = resource.parse::<Resource>().unwrap_err();
535
536 assert_eq!(error, ResourceError::MissingHttpAuthority);
537 }
538 }
539
540 /// Validates HTTP and HTTPS resource authorities regardless of scheme case.
541 ///
542 /// URI schemes are case-insensitive, so uppercase `HTTPS` should not bypass the stricter
543 /// hierarchical URI validation used for HTTP resources.
544 #[test]
545 fn rejects_invalid_https_authority_with_uppercase_scheme() {
546 for resource in ["HTTPS://[::1", "https:///profile"] {
547 let error = resource.parse::<Resource>().unwrap_err();
548
549 assert!(
550 matches!(error, ResourceError::InvalidHttpUri(_)),
551 "expected invalid-authority error for {resource:?}, got {error:?}",
552 );
553 }
554 }
555}