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        // SAFETY: `new` is the infallible constructor reserved for inputs the caller
45        // has already validated (compile-time literals, values that
46        // round-tripped through `try_new` at a boundary). Untrusted input must
47        // go through `try_new`.
48        Self::try_new(value).expect("ValidatedUrl validation failed")
49    }
50
51    #[must_use]
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55
56    #[must_use]
57    pub fn scheme(&self) -> &str {
58        self.0.split("://").next().unwrap_or("")
59    }
60
61    #[must_use]
62    pub fn is_https(&self) -> bool {
63        self.scheme().eq_ignore_ascii_case("https")
64    }
65
66    #[must_use]
67    pub fn is_http(&self) -> bool {
68        let scheme = self.scheme().to_ascii_lowercase();
69        scheme == "http" || scheme == "https"
70    }
71}
72
73fn validate_scheme(scheme: &str) -> Result<(), IdValidationError> {
74    if scheme.is_empty() {
75        return Err(IdValidationError::invalid(
76            "ValidatedUrl",
77            "scheme cannot be empty",
78        ));
79    }
80    if !scheme
81        .chars()
82        .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
83    {
84        return Err(IdValidationError::invalid(
85            "ValidatedUrl",
86            "scheme contains invalid characters",
87        ));
88    }
89    if !scheme.starts_with(|c: char| c.is_ascii_alphabetic()) {
90        return Err(IdValidationError::invalid(
91            "ValidatedUrl",
92            "scheme must start with a letter",
93        ));
94    }
95    Ok(())
96}
97
98fn validate_authority(after_scheme: &str, scheme: &str) -> Result<(), IdValidationError> {
99    let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
100    let authority = &after_scheme[..host_end];
101    let host_part = authority
102        .rfind('@')
103        .map_or(authority, |i| &authority[i + 1..]);
104
105    let host = if host_part.starts_with('[') {
106        let bracket_end = host_part.find(']').ok_or_else(|| {
107            IdValidationError::invalid("ValidatedUrl", "IPv6 address missing closing bracket")
108        })?;
109        &host_part[..=bracket_end]
110    } else {
111        host_part.split(':').next().unwrap_or(host_part)
112    };
113
114    if host.starts_with('[') && host.ends_with(']') {
115        let ipv6_content = &host[1..host.len() - 1];
116        if ipv6_content.is_empty() {
117            return Err(IdValidationError::invalid(
118                "ValidatedUrl",
119                "IPv6 address cannot be empty",
120            ));
121        }
122    }
123
124    if host_part.contains("]:") || (!host_part.starts_with('[') && host_part.contains(':')) {
125        let port_part = if host_part.starts_with('[') {
126            host_part.rsplit("]:").next()
127        } else {
128            host_part.split(':').nth(1)
129        };
130        if let Some(port) = port_part {
131            if port.is_empty() || port.starts_with('/') {
132                return Err(IdValidationError::invalid(
133                    "ValidatedUrl",
134                    "port cannot be empty when ':' is present",
135                ));
136            }
137        }
138    }
139
140    if host.is_empty() && !scheme.eq_ignore_ascii_case("file") {
141        return Err(IdValidationError::invalid(
142            "ValidatedUrl",
143            "host cannot be empty",
144        ));
145    }
146    Ok(())
147}
148
149impl fmt::Display for ValidatedUrl {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(f, "{}", self.0)
152    }
153}
154
155impl TryFrom<String> for ValidatedUrl {
156    type Error = IdValidationError;
157
158    fn try_from(s: String) -> Result<Self, Self::Error> {
159        Self::try_new(s)
160    }
161}
162
163impl TryFrom<&str> for ValidatedUrl {
164    type Error = IdValidationError;
165
166    fn try_from(s: &str) -> Result<Self, Self::Error> {
167        Self::try_new(s)
168    }
169}
170
171impl std::str::FromStr for ValidatedUrl {
172    type Err = IdValidationError;
173
174    fn from_str(s: &str) -> Result<Self, Self::Err> {
175        Self::try_new(s)
176    }
177}
178
179impl AsRef<str> for ValidatedUrl {
180    fn as_ref(&self) -> &str {
181        &self.0
182    }
183}
184
185impl<'de> Deserialize<'de> for ValidatedUrl {
186    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
187    where
188        D: serde::Deserializer<'de>,
189    {
190        let s = String::deserialize(deserializer)?;
191        Self::try_new(s).map_err(serde::de::Error::custom)
192    }
193}
194
195impl ToDbValue for ValidatedUrl {
196    fn to_db_value(&self) -> DbValue {
197        DbValue::String(self.0.clone())
198    }
199}
200
201impl ToDbValue for &ValidatedUrl {
202    fn to_db_value(&self) -> DbValue {
203        DbValue::String(self.0.clone())
204    }
205}