Skip to main content

systemprompt_identifiers/
url.rs

1//! Validated URL type.
2
3use crate::error::IdValidationError;
4use crate::{DbValue, ToDbValue};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
9#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
10#[cfg_attr(feature = "sqlx", sqlx(transparent))]
11#[serde(transparent)]
12pub struct ValidatedUrl(String);
13
14impl ValidatedUrl {
15    pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
16        let value = value.into();
17        if value.is_empty() {
18            return Err(IdValidationError::empty("ValidatedUrl"));
19        }
20        let scheme_end = value.find("://").ok_or_else(|| {
21            IdValidationError::invalid("ValidatedUrl", "must have a scheme (e.g., 'https://')")
22        })?;
23        let scheme = &value[..scheme_end];
24        validate_scheme(scheme)?;
25
26        let after_scheme = &value[scheme_end + 3..];
27        if after_scheme.is_empty() {
28            return Err(IdValidationError::invalid(
29                "ValidatedUrl",
30                "URL must have a host component",
31            ));
32        }
33        validate_authority(after_scheme, scheme)?;
34        Ok(Self(value))
35    }
36
37    #[must_use]
38    #[expect(
39        clippy::expect_used,
40        reason = "infallible constructor reserved for already-validated inputs; untrusted input \
41                  must go through try_new"
42    )]
43    pub fn new(value: impl Into<String>) -> Self {
44        Self::try_new(value).expect("ValidatedUrl validation failed")
45    }
46
47    #[must_use]
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51
52    #[must_use]
53    pub fn scheme(&self) -> &str {
54        self.0.split("://").next().unwrap_or("")
55    }
56
57    #[must_use]
58    pub fn is_https(&self) -> bool {
59        self.scheme().eq_ignore_ascii_case("https")
60    }
61
62    #[must_use]
63    pub fn is_http(&self) -> bool {
64        let scheme = self.scheme().to_ascii_lowercase();
65        scheme == "http" || scheme == "https"
66    }
67}
68
69fn validate_scheme(scheme: &str) -> Result<(), IdValidationError> {
70    if scheme.is_empty() {
71        return Err(IdValidationError::invalid(
72            "ValidatedUrl",
73            "scheme cannot be empty",
74        ));
75    }
76    if !scheme
77        .chars()
78        .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
79    {
80        return Err(IdValidationError::invalid(
81            "ValidatedUrl",
82            "scheme contains invalid characters",
83        ));
84    }
85    if !scheme.starts_with(|c: char| c.is_ascii_alphabetic()) {
86        return Err(IdValidationError::invalid(
87            "ValidatedUrl",
88            "scheme must start with a letter",
89        ));
90    }
91    Ok(())
92}
93
94fn validate_authority(after_scheme: &str, scheme: &str) -> Result<(), IdValidationError> {
95    let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
96    let authority = &after_scheme[..host_end];
97    let host_part = authority
98        .rfind('@')
99        .map_or(authority, |i| &authority[i + 1..]);
100
101    let host = if host_part.starts_with('[') {
102        let bracket_end = host_part.find(']').ok_or_else(|| {
103            IdValidationError::invalid("ValidatedUrl", "IPv6 address missing closing bracket")
104        })?;
105        &host_part[..=bracket_end]
106    } else {
107        host_part.split(':').next().unwrap_or(host_part)
108    };
109
110    if host.starts_with('[') && host.ends_with(']') {
111        let ipv6_content = &host[1..host.len() - 1];
112        if ipv6_content.is_empty() {
113            return Err(IdValidationError::invalid(
114                "ValidatedUrl",
115                "IPv6 address cannot be empty",
116            ));
117        }
118    }
119
120    if host_part.contains("]:") || (!host_part.starts_with('[') && host_part.contains(':')) {
121        let port_part = if host_part.starts_with('[') {
122            host_part.rsplit("]:").next()
123        } else {
124            host_part.split(':').nth(1)
125        };
126        if let Some(port) = port_part
127            && (port.is_empty() || port.starts_with('/'))
128        {
129            return Err(IdValidationError::invalid(
130                "ValidatedUrl",
131                "port cannot be empty when ':' is present",
132            ));
133        }
134    }
135
136    if host.is_empty() && !scheme.eq_ignore_ascii_case("file") {
137        return Err(IdValidationError::invalid(
138            "ValidatedUrl",
139            "host cannot be empty",
140        ));
141    }
142    Ok(())
143}
144
145impl fmt::Display for ValidatedUrl {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(f, "{}", self.0)
148    }
149}
150
151impl TryFrom<String> for ValidatedUrl {
152    type Error = IdValidationError;
153
154    fn try_from(s: String) -> Result<Self, Self::Error> {
155        Self::try_new(s)
156    }
157}
158
159impl TryFrom<&str> for ValidatedUrl {
160    type Error = IdValidationError;
161
162    fn try_from(s: &str) -> Result<Self, Self::Error> {
163        Self::try_new(s)
164    }
165}
166
167impl std::str::FromStr for ValidatedUrl {
168    type Err = IdValidationError;
169
170    fn from_str(s: &str) -> Result<Self, Self::Err> {
171        Self::try_new(s)
172    }
173}
174
175impl AsRef<str> for ValidatedUrl {
176    fn as_ref(&self) -> &str {
177        &self.0
178    }
179}
180
181impl<'de> Deserialize<'de> for ValidatedUrl {
182    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
183    where
184        D: serde::Deserializer<'de>,
185    {
186        let s = String::deserialize(deserializer)?;
187        Self::try_new(s).map_err(serde::de::Error::custom)
188    }
189}
190
191impl ToDbValue for ValidatedUrl {
192    fn to_db_value(&self) -> DbValue {
193        DbValue::String(self.0.clone())
194    }
195}
196
197impl ToDbValue for &ValidatedUrl {
198    fn to_db_value(&self) -> DbValue {
199        DbValue::String(self.0.clone())
200    }
201}