Skip to main content

rustical_dav/
lib.rs

1#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
2#![allow(clippy::missing_errors_doc)]
3pub mod error;
4pub mod extensions;
5pub mod header;
6pub mod namespace;
7pub mod privileges;
8pub mod resource;
9pub mod resources;
10pub mod xml;
11use std::borrow::Cow;
12
13pub use error::Error;
14use http::Uri;
15use itertools::Itertools;
16use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str};
17
18/// Minimal Principal trait for a WebDAV service.
19/// For the purpose of WebDAV we only need to identify a principal id
20/// to correctly return current-user-principal.
21pub trait Principal: std::fmt::Debug + Clone + Send + Sync + 'static {
22    fn get_id(&self) -> &str;
23}
24
25/// Characters that need to be percent-encoded according to WebDAV spec
26///   ```txt
27///   reserved    = gen-delims / sub-delims
28///
29///   gen-delims  = ":" / "/" / "?" / "#" / "[" / "]" / "@"
30///
31///   sub-delims  = "!" / "$" / "&" / "'" / "(" / ")"
32///               / "*" / "+" / "," / ";" / "="
33///   ```
34const RFC_3986: &AsciiSet = &CONTROLS
35    .add(b':')
36    .add(b'/')
37    .add(b'?')
38    .add(b'#')
39    .add(b'[')
40    .add(b']')
41    .add(b'@')
42    .add(b'!')
43    .add(b'$')
44    .add(b'&')
45    .add(b'\\')
46    .add(b'(')
47    .add(b')')
48    .add(b'*')
49    .add(b'+')
50    .add(b',')
51    .add(b';')
52    .add(b'=')
53    // Not in RFC 3986 but also necessary
54    .add(b' ');
55
56#[inline]
57#[must_use]
58pub fn rfc_3986_percent_encode(input: &'_ str) -> percent_encoding::PercentEncode<'_> {
59    percent_encoding::percent_encode(input.as_bytes(), RFC_3986)
60}
61
62#[cfg(test)]
63mod tests {
64    use crate::rfc_3986_percent_encode;
65
66    /// We need to make sure that URI-reserved characters are encoded but not "."
67    #[rstest::rstest]
68    #[case("hello.ics", "hello.ics")]
69    #[case("hello@example.com", "hello%40example.com")]
70    #[case("slash/slash", "slash%2Fslash")]
71    fn test_percent_encoding(#[case] input: &str, #[case] expected_result: &str) {
72        assert_eq!(&rfc_3986_percent_encode(input).to_string(), expected_result);
73    }
74}
75
76pub fn resolve_child_uri<'a>(
77    collection_uri: &'_ Uri,
78    child_uri: &'a Uri,
79) -> Option<Vec<Cow<'a, str>>> {
80    // If explicit scheme given for child it MUST match
81    if let Some(child_scheme) = child_uri.scheme() {
82        let collection_scheme = collection_uri.scheme()?;
83        if child_scheme != collection_scheme {
84            return None;
85        }
86    }
87
88    // If explicit authority given for child it MUST match
89    if let Some(child_authority) = child_uri.authority() {
90        let collection_authority = collection_uri.authority()?;
91        if child_authority != collection_authority {
92            return None;
93        }
94    }
95
96    // To properly handle urldecoding we must handle individual path segments
97    let collection_path_segments = collection_uri
98        .path()
99        .split('/')
100        .filter(|seg| !seg.is_empty());
101    let mut child_path_segments = child_uri.path().split('/').filter(|seg| !seg.is_empty());
102
103    for collection_segment in collection_path_segments {
104        let Some(child_segment) = child_path_segments.next() else {
105            // child_uri is not child of collection_uri
106            return None;
107        };
108        // Make sure that paths on same level match
109        if percent_decode_str(collection_segment).collect_vec()
110            != percent_decode_str(child_segment).collect_vec()
111        {
112            return None;
113        }
114    }
115
116    child_path_segments
117        .map(|segment| percent_decode_str(segment).decode_utf8().ok())
118        .collect()
119}
120
121#[cfg(test)]
122mod uri_tests {
123    use crate::resolve_child_uri;
124    use http::Uri;
125    use std::borrow::Cow;
126
127    #[rstest::rstest]
128    #[case("https://rustical.example.com", "/hello", Some(vec!["hello".into()]))]
129    #[case("https://rustical.example.com/", "/hello", Some(vec!["hello".into()]))]
130    // Not absolute
131    #[case("https://rustical.example.com/", "hello", None)]
132    // Different origins
133    #[case(
134        "https://rustical.example.com/caldav/principal/user%40example%2Ecom/cal/",
135        "https://cal.hello.dev/caldav/principal/user%40example%2Ecom/cal/hello.ics",
136        None
137    )]
138    // Trivial, both equally escaped
139    #[case(
140        "/caldav/principal/user%40example%2Ecom/cal/",
141        "/caldav/principal/user%40example%2Ecom/cal/hello.ics",
142        Some(vec!["hello.ics".into()])
143    )]
144    // Both escaped differently
145    #[case(
146        "/caldav/principal/user%40example%2Ecom/cal/",
147        "/caldav/principal/user@example.com/cal/unescaped.ics",
148        Some(vec!["unescaped.ics".into()])
149    )]
150    // Both escaped differently
151    #[case(
152        "/caldav/principal/user%40example%2Ecom/cal/",
153        "/caldav/principal/user%40example.com/cal/shouldwork.ics",
154        Some(vec!["shouldwork.ics".into()])
155    )]
156    // Both escaped, different paths
157    #[case(
158        "/caldav/principal/user%40example%2Ecom/cal/",
159        "/caldav/principal/user%40example%2Ecom/nocal/hello.ics",
160        None
161    )]
162    // Both escaped differently
163    #[case(
164        "/caldav/principal/user%40example.com/cal/",
165        "/caldav/principal/user%40example%2Ecom/cal/hello.ics",
166        Some(vec!["hello.ics".into()])
167    )]
168    // Both escaped differently
169    #[case(
170        "/caldav/principal/user%40example.com/cal/",
171        "/caldav/principal/user@example.com/cal/unescaped.ics",
172        Some(vec!["unescaped.ics".into()])
173    )]
174    // Both escaped differently
175    #[case(
176        "/caldav/principal/user%40example.com/cal/",
177        "/caldav/principal/user%40example.com/cal/shouldwork.ics",
178        Some(vec!["shouldwork.ics".into()])
179    )]
180    // Different paths
181    #[case(
182        "/caldav/principal/user%40example.com/cal/",
183        "/caldav/principal/user%40example%2Ecom/nocal/hello.ics",
184        None
185    )]
186    // Empty root path
187    #[case("https://example.com", "/hello.ics", Some(vec!["hello.ics".into()]))]
188    // Escaped child path
189    #[case("https://example.com", "/hello%2Eics", Some(vec!["hello.ics".into()]))]
190    fn test_resolve_child_uri(
191        #[case] collection: &'static str,
192        #[case] child: &'static str,
193        #[case] expected_out: Option<Vec<Cow<'static, str>>>,
194    ) {
195        let collection_uri = Uri::from_static(collection);
196        let child_uri = Uri::from_static(child);
197
198        assert_eq!(resolve_child_uri(&collection_uri, &child_uri), expected_out);
199    }
200}