Skip to main content

systemprompt_identifiers/
url.rs

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