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}