pagesift_engine/
discovery.rs1use url::Url;
4
5fn split_field(value: &str, delimiter: char) -> Vec<&str> {
7 let (mut quoted, mut escaped, mut angle, mut start) = (false, false, false, 0);
8 let mut parts = Vec::new();
9 for (i, c) in value.char_indices() {
10 if escaped {
11 escaped = false;
12 } else if quoted && c == '\\' {
13 escaped = true;
14 } else if c == '"' && !angle {
15 quoted = !quoted;
16 } else if !quoted {
17 match c {
18 '<' => angle = true,
19 '>' => angle = false,
20 _ if c == delimiter && !angle => {
21 parts.push(&value[start..i]);
22 start = i + c.len_utf8();
23 }
24 _ => {}
25 }
26 }
27 }
28 if !quoted && !angle {
29 parts.push(&value[start..]);
30 }
31 parts
32}
33
34pub fn header_links<'a>(base: &Url, fields: impl Iterator<Item = &'a str>) -> Vec<String> {
35 let mut out = Vec::new();
36 for field in fields {
37 for item in split_field(field, ',') {
38 let item = item.trim();
39 let Some(rest) = item.strip_prefix('<') else {
40 continue;
41 };
42 let Some((target, params)) = rest.split_once('>') else {
43 continue;
44 };
45 if !params.trim_start().starts_with(';') {
46 continue;
47 }
48 let mut navigation = false;
49 let mut anchored = false;
50 for param in split_field(params, ';') {
51 let Some((name, value)) = param.trim().split_once('=') else {
52 continue;
53 };
54 let name = name.trim();
55 let value = value.trim();
56 anchored |= name.eq_ignore_ascii_case("anchor");
59 if name.eq_ignore_ascii_case("rel") {
60 let value = value
61 .strip_prefix('"')
62 .and_then(|v| v.strip_suffix('"'))
63 .unwrap_or(value);
64 navigation |= value
65 .split_ascii_whitespace()
66 .any(|r| r.eq_ignore_ascii_case("next") || r.eq_ignore_ascii_case("prev"));
67 }
68 }
69 if !navigation || anchored || target.trim().is_empty() || target.starts_with('#') {
70 continue;
71 }
72 if let Ok(mut url) = base.join(target)
73 && matches!(url.scheme(), "http" | "https")
74 {
75 url.set_fragment(None);
76 let url = url.to_string();
77 if !out.contains(&url) {
78 out.push(url);
79 }
80 }
81 }
82 }
83 out
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89 #[test]
90 fn navigation_respects_field_syntax_and_context() {
91 let base = Url::parse("https://example.com/articles/1").unwrap();
92 let fields = [
93 r#"<2?a=1,2>; title="a,b;c"; rel="next", </style>; rel=preload"#,
94 r#"</other>; rel="prev"; anchor="/different", <javascript:bad>; rel=next"#,
95 r#"</previous#part>; REL="PREV", </previous>; rel=prev, </canonical>; rel=canonical"#,
96 r#"</bad>; rel="next"; title="unterminated"#,
97 ];
98 assert_eq!(
99 header_links(&base, fields.into_iter()),
100 [
101 "https://example.com/articles/2?a=1,2",
102 "https://example.com/previous"
103 ]
104 );
105 }
106}