Skip to main content

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}