Skip to main content

discovery/
link.rs

1use url::Url;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct Link {
5    pub href: String,
6    pub rel: String,
7    pub media_type: Option<String>,
8    pub title: Option<String>,
9    pub params: std::collections::BTreeMap<String, String>,
10}
11
12#[must_use]
13pub fn parse_link_header(raw: &str, base: &Url) -> Vec<Link> {
14    let mut links = Vec::new();
15    for line in raw.split('\n') {
16        parse_header_value(line.trim(), base, &mut links);
17    }
18    links
19}
20
21fn parse_header_value(input: &str, base: &Url, out: &mut Vec<Link>) {
22    let mut remaining = input;
23    while !remaining.is_empty() {
24        remaining = remaining.trim_start();
25        if remaining.is_empty() {
26            break;
27        }
28        if !remaining.starts_with('<') {
29            if let Some(pos) = remaining.find('<') {
30                remaining = &remaining[pos..];
31                continue;
32            }
33            break;
34        }
35        remaining = &remaining[1..];
36        let close = match remaining.find('>') {
37            Some(p) => p,
38            None => break,
39        };
40        let raw_href = &remaining[..close];
41        remaining = &remaining[close + 1..];
42
43        let href = resolve_href(raw_href.trim(), base);
44        let mut rel = None;
45        let mut media_type = None;
46        let mut title = None;
47        let mut title_star_seen = false;
48        let mut has_anchor = false;
49        let mut extra_params = std::collections::BTreeMap::new();
50
51        loop {
52            remaining = remaining.trim_start();
53            if remaining.starts_with(';') {
54                remaining = remaining[1..].trim_start();
55            } else {
56                break;
57            }
58
59            let (key, value, rest) = parse_param(remaining);
60            remaining = rest;
61
62            match key.to_ascii_lowercase().as_str() {
63                "rel" => {
64                    if rel.is_none() {
65                        rel = Some(value);
66                    }
67                }
68                "type" => {
69                    if media_type.is_none() {
70                        media_type = Some(value);
71                    }
72                }
73                "title" => {
74                    if title.is_none() {
75                        title = Some(value);
76                    }
77                }
78                "title*" => {
79                    if !title_star_seen {
80                        if let Some(decoded) = decode_ext_value(&value) {
81                            title_star_seen = true;
82                            title = Some(decoded);
83                        }
84                    }
85                }
86                "anchor" => has_anchor = true,
87                other => {
88                    let norm = other.strip_suffix('*').unwrap_or(other);
89                    if !extra_params.contains_key(norm) {
90                        let decoded = if other.ends_with('*') {
91                            decode_ext_value(&value).unwrap_or(value)
92                        } else {
93                            value
94                        };
95                        extra_params.insert(norm.to_string(), decoded);
96                    }
97                }
98            }
99        }
100
101        if remaining.starts_with(',') {
102            remaining = &remaining[1..];
103        }
104
105        if has_anchor {
106            continue;
107        }
108
109        if let Some(rel_val) = rel {
110            for r in rel_val.split_ascii_whitespace() {
111                out.push(Link {
112                    href: href.clone(),
113                    rel: r.to_ascii_lowercase(),
114                    media_type: media_type.clone(),
115                    title: title.clone(),
116                    params: extra_params.clone(),
117                });
118            }
119        }
120    }
121}
122
123fn resolve_href(raw: &str, base: &Url) -> String {
124    match base.join(raw) {
125        Ok(u) => u.to_string(),
126        Err(_) => raw.to_string(),
127    }
128}
129
130fn parse_param(input: &str) -> (String, String, &str) {
131    let mut remaining = input;
132    let key_end = remaining
133        .find(['=', ';', ',', '<'])
134        .unwrap_or(remaining.len());
135    let key = remaining[..key_end].trim().to_string();
136    remaining = &remaining[key_end..];
137
138    if !remaining.starts_with('=') {
139        return (key, String::new(), remaining);
140    }
141    remaining = remaining[1..].trim_start();
142
143    if remaining.starts_with('"') {
144        let (val, rest) = parse_quoted(remaining);
145        (key, val, rest)
146    } else {
147        let end = remaining
148            .find([';', ',', '<'])
149            .unwrap_or(remaining.len());
150        let val = remaining[..end].trim().to_string();
151        (key, val, &remaining[end..])
152    }
153}
154
155fn parse_quoted(input: &str) -> (String, &str) {
156    let mut chars = input[1..].chars();
157    let mut value = String::new();
158    let mut consumed = 1;
159    loop {
160        match chars.next() {
161            None => break,
162            Some('"') => {
163                consumed += 1;
164                break;
165            }
166            Some('\\') => {
167                consumed += 1;
168                if let Some(c) = chars.next() {
169                    value.push(c);
170                    consumed += c.len_utf8();
171                }
172            }
173            Some(c) => {
174                value.push(c);
175                consumed += c.len_utf8();
176            }
177        }
178    }
179    (value, &input[consumed..])
180}
181
182fn decode_ext_value(raw: &str) -> Option<String> {
183    let apos1 = raw.find('\'')?;
184    let rest = &raw[apos1 + 1..];
185    let apos2 = rest.find('\'')?;
186    let encoded = &rest[apos2 + 1..];
187
188    let charset = raw[..apos1].to_ascii_uppercase();
189    if charset != "UTF-8" {
190        return None;
191    }
192
193    percent_decode(encoded)
194}
195
196fn percent_decode(input: &str) -> Option<String> {
197    let mut bytes = Vec::new();
198    let mut chars = input.bytes();
199    loop {
200        match chars.next() {
201            None => break,
202            Some(b'%') => {
203                let hi = chars.next()?;
204                let lo = chars.next()?;
205                let val = hex_val(hi)? << 4 | hex_val(lo)?;
206                bytes.push(val);
207            }
208            Some(b) => bytes.push(b),
209        }
210    }
211    String::from_utf8(bytes).ok()
212}
213
214fn hex_val(b: u8) -> Option<u8> {
215    match b {
216        b'0'..=b'9' => Some(b - b'0'),
217        b'a'..=b'f' => Some(b - b'a' + 10),
218        b'A'..=b'F' => Some(b - b'A' + 10),
219        _ => None,
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn base() -> Url {
228        Url::parse("https://example.com/page").unwrap()
229    }
230
231    #[test]
232    fn simple_link() {
233        let links = parse_link_header(r#"</.well-known/ard.json>; rel="ard"; type="application/json""#, &base());
234        assert_eq!(links.len(), 1);
235        assert_eq!(links[0].href, "https://example.com/.well-known/ard.json");
236        assert_eq!(links[0].rel, "ard");
237        assert_eq!(links[0].media_type.as_deref(), Some("application/json"));
238    }
239
240    #[test]
241    fn multiple_links_comma_separated() {
242        let links = parse_link_header(
243            r#"</a>; rel="first", </b>; rel="second""#,
244            &base(),
245        );
246        assert_eq!(links.len(), 2);
247        assert_eq!(links[0].rel, "first");
248        assert_eq!(links[1].rel, "second");
249    }
250
251    #[test]
252    fn multiple_header_instances_newline() {
253        let links = parse_link_header(
254            "</a>; rel=\"one\"\n</b>; rel=\"two\"",
255            &base(),
256        );
257        assert_eq!(links.len(), 2);
258        assert_eq!(links[0].rel, "one");
259        assert_eq!(links[1].rel, "two");
260    }
261
262    #[test]
263    fn quoted_comma_in_title() {
264        let links = parse_link_header(
265            r#"</x>; rel="thing"; title="a, b""#,
266            &base(),
267        );
268        assert_eq!(links.len(), 1);
269        assert_eq!(links[0].title.as_deref(), Some("a, b"));
270    }
271
272    #[test]
273    fn ext_value_title_star() {
274        let links = parse_link_header(
275            "</x>; rel=\"thing\"; title*=UTF-8''caf%C3%A9",
276            &base(),
277        );
278        assert_eq!(links.len(), 1);
279        assert_eq!(links[0].title.as_deref(), Some("café"));
280    }
281
282    #[test]
283    fn title_star_overrides_title() {
284        let links = parse_link_header(
285            "</x>; rel=\"thing\"; title=\"ascii\"; title*=UTF-8''caf%C3%A9",
286            &base(),
287        );
288        assert_eq!(links.len(), 1);
289        assert_eq!(links[0].title.as_deref(), Some("café"));
290    }
291
292    #[test]
293    fn relative_href() {
294        let b = Url::parse("https://example.com/dir/page").unwrap();
295        let links = parse_link_header(r#"<../other>; rel="rel""#, &b);
296        assert_eq!(links[0].href, "https://example.com/other");
297    }
298
299    #[test]
300    fn absolute_href() {
301        let links = parse_link_header(
302            r#"<https://cdn.example.com/x>; rel="preload""#,
303            &base(),
304        );
305        assert_eq!(links[0].href, "https://cdn.example.com/x");
306    }
307
308    #[test]
309    fn empty_header() {
310        let links = parse_link_header("", &base());
311        assert!(links.is_empty());
312    }
313
314    #[test]
315    fn no_rel_skipped() {
316        let links = parse_link_header("</x>; type=\"text/plain\"", &base());
317        assert!(links.is_empty());
318    }
319
320    #[test]
321    fn multiple_rels() {
322        let links = parse_link_header(r#"</x>; rel="a b""#, &base());
323        assert_eq!(links.len(), 2);
324        assert_eq!(links[0].rel, "a");
325        assert_eq!(links[1].rel, "b");
326    }
327
328    #[test]
329    fn rel_case_insensitive() {
330        let links = parse_link_header(r#"</x>; rel="ARD""#, &base());
331        assert_eq!(links[0].rel, "ard");
332    }
333
334    #[test]
335    fn duplicate_rel_first_wins() {
336        let links = parse_link_header(
337            r#"</x>; rel="first"; rel="second""#,
338            &base(),
339        );
340        assert_eq!(links.len(), 1);
341        assert_eq!(links[0].rel, "first");
342    }
343
344    #[test]
345    fn duplicate_type_first_wins() {
346        let links = parse_link_header(
347            r#"</x>; rel="r"; type="text/plain"; type="application/json""#,
348            &base(),
349        );
350        assert_eq!(links[0].media_type.as_deref(), Some("text/plain"));
351    }
352
353    #[test]
354    fn duplicate_title_first_wins() {
355        let links = parse_link_header(
356            r#"</x>; rel="r"; title="first"; title="second""#,
357            &base(),
358        );
359        assert_eq!(links[0].title.as_deref(), Some("first"));
360    }
361
362    #[test]
363    fn whitespace_around_equals() {
364        let links = parse_link_header(
365            r#"</x>; rel = "ard"; type = "application/json""#,
366            &base(),
367        );
368        assert_eq!(links.len(), 1);
369        assert_eq!(links[0].rel, "ard");
370        assert_eq!(links[0].media_type.as_deref(), Some("application/json"));
371    }
372
373    #[test]
374    fn anchor_parameter_skips_link() {
375        let links = parse_link_header(
376            r#"</x>; rel="ard"; anchor="/other""#,
377            &base(),
378        );
379        assert!(links.is_empty());
380    }
381
382    #[test]
383    fn anchor_on_one_link_does_not_affect_others() {
384        let links = parse_link_header(
385            r#"</a>; rel="first"; anchor="/z", </b>; rel="second""#,
386            &base(),
387        );
388        assert_eq!(links.len(), 1);
389        assert_eq!(links[0].rel, "second");
390    }
391
392    #[test]
393    fn duplicate_title_star_first_wins() {
394        let links = parse_link_header(
395            "</x>; rel=\"r\"; title*=UTF-8''first; title*=UTF-8''second",
396            &base(),
397        );
398        assert_eq!(links[0].title.as_deref(), Some("first"));
399    }
400}