aede_core/parental/
feeds.rs

1//! RSS address identity normalization without network access.
2/// Normalize an RSS identity without fetching it or interpreting its contents.
3pub 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;