aede_core/parental/
feeds.rs1pub fn canonical_feed(value: &str) -> Result<String, String> {
4 if value.is_empty()
5 || value.len() > 2048
6 || !value.is_ascii()
7 || value.chars().any(|c| c.is_control() || c.is_whitespace())
8 || value.contains('\\')
9 {
10 return Err("invalid podcast feed address".into());
11 }
12 let (scheme, rest) = value
13 .split_once("://")
14 .ok_or("invalid podcast feed address")?;
15 let scheme = scheme.to_ascii_lowercase();
16 if !matches!(scheme.as_str(), "http" | "https") {
17 return Err("invalid podcast feed address".into());
18 }
19 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
20 let authority = &rest[..authority_end];
21 if authority.is_empty() || authority.contains('@') {
22 return Err("invalid podcast feed authority".into());
23 }
24 let (host, port) = if authority.starts_with('[') {
25 let end = authority
26 .find(']')
27 .ok_or("invalid podcast feed authority")?;
28 authority[1..end]
29 .parse::<std::net::Ipv6Addr>()
30 .map_err(|_| "invalid podcast feed authority")?;
31 let tail = &authority[end + 1..];
32 (
33 &authority[..=end],
34 if tail.is_empty() {
35 None
36 } else {
37 Some(tail.strip_prefix(':').ok_or("invalid podcast feed port")?)
38 },
39 )
40 } else {
41 let (host, port) = authority
42 .rsplit_once(':')
43 .map_or((authority, None), |(host, port)| (host, Some(port)));
44 let host = host.strip_suffix('.').unwrap_or(host);
45 if host.len() > 253
46 || host.split('.').any(|part| {
47 part.is_empty()
48 || part.len() > 63
49 || part.starts_with('-')
50 || part.ends_with('-')
51 || !part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
52 })
53 {
54 return Err("invalid podcast feed authority".into());
55 }
56 (host, port)
57 };
58 let port = port
59 .map(|port| {
60 port.parse::<u16>()
61 .ok()
62 .filter(|port| *port > 0)
63 .ok_or("invalid podcast feed port")
64 })
65 .transpose()?;
66 let host = if host.starts_with('[') {
67 format!(
68 "[{}]",
69 host[1..host.len() - 1]
70 .parse::<std::net::Ipv6Addr>()
71 .map_err(|_| "invalid podcast feed authority")?
72 )
73 } else {
74 host.to_ascii_lowercase()
75 };
76 let mut normalized = format!("{scheme}://{host}");
77 if let Some(port) =
78 port.filter(|port| !matches!((scheme.as_str(), port), ("http", 80) | ("https", 443)))
79 {
80 normalized.push_str(&format!(":{port}"));
81 }
82 let suffix = rest[authority_end..].split('#').next().unwrap_or("");
83 let (path, query) = suffix
84 .split_once('?')
85 .map_or((suffix, None), |(path, query)| (path, Some(query)));
86 normalized.push_str(&normalize_path(path)?);
87 if let Some(query) = query {
88 normalized.push('?');
89 normalized.push_str(&normalize_percent(query)?);
90 }
91 Ok(normalized)
92}
93fn normalize_percent(value: &str) -> Result<String, String> {
94 let bytes = value.as_bytes();
95 let mut output = String::with_capacity(bytes.len());
96 let mut index = 0;
97 while index < bytes.len() {
98 if bytes[index] != b'%' {
99 output.push(bytes[index] as char);
100 index += 1;
101 continue;
102 }
103 let pair = bytes
104 .get(index + 1..index + 3)
105 .ok_or("invalid podcast percent encoding")?;
106 let high = (pair[0] as char)
107 .to_digit(16)
108 .ok_or("invalid podcast percent encoding")?;
109 let low = (pair[1] as char)
110 .to_digit(16)
111 .ok_or("invalid podcast percent encoding")?;
112 let byte = (high * 16 + low) as u8;
113 if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
114 output.push(byte as char);
115 } else {
116 output.push_str(&format!("%{byte:02X}"));
117 }
118 index += 3;
119 }
120 Ok(output)
121}
122fn normalize_path(value: &str) -> Result<String, String> {
123 let value = normalize_percent(value)?;
124 let trailing = value.ends_with('/') || value.ends_with("/.") || value.ends_with("/..");
125 let mut segments = Vec::new();
126 for segment in value.strip_prefix('/').unwrap_or(&value).split('/') {
127 match segment {
128 "." => {}
129 ".." => {
130 segments.pop();
131 }
132 other => segments.push(other),
133 }
134 }
135 let mut output = format!("/{}", segments.join("/"));
136 if trailing && !output.ends_with('/') {
137 output.push('/');
138 }
139 Ok(output)
140}
141#[cfg(test)]
142#[path = "feeds_tests.rs"]
143mod tests;