stalkermap/utils/
url.rs

1//! # UrlParser
2//!
3//! A minimal and safe URL parser in Rust.
4//!
5//! **Features:**
6//! - Supports `http` and `https` schemes
7//! - Host validation (`DNS`, `IPv4`, `IPv6`)
8//! - Custom error enum for precise error handling
9//! - No external dependencies
10//!
11//! ## Example
12//!
13//! ```rust,no_run
14//! use std::str::FromStr;
15//! use std::convert::TryFrom;
16//! use stalkermap::utils::UrlParser;
17//!
18//! // Via `new` constructor (returns Result)
19//! let url = UrlParser::new("<https://example.com>").unwrap();
20//!
21//! // Via `parse` using FromStr (returns Result)
22//! let url2: UrlParser = "<https://example.com>".parse().unwrap();
23//!
24//! // Via TryFrom (returns Result)
25//! let url3 = UrlParser::try_from("<https://example.com>").unwrap();
26//!
27//! // Note: `From<&str>` is intentionally not implemented, because parsing may fail.
28//! // Users should use `new`, `parse`, or `TryFrom` for safe URL creation.
29//! ```
30use std::str::FromStr;
31use std::{
32    error::Error,
33    fmt::Display,
34    net::{Ipv4Addr, Ipv6Addr},
35};
36
37/// Represents a parsed URL.
38///
39/// Contains information about:
40/// - [`Scheme`] (`http` or `https`)
41/// - [`TargetType`] (DNS, IPv4, IPv6)
42/// - Associated port (0 is the default and its not included on the url)
43/// - Full normalized URL
44#[derive(Debug, PartialEq)]
45pub struct UrlParser {
46    pub scheme: Scheme,
47    pub target: String,
48    pub target_type: TargetType,
49    pub port: u16,
50    pub subdirectory: String,
51    pub full_url: String,
52}
53
54impl Display for UrlParser {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(
57            f,
58            "Scheme: {}\nTarget: {}\nTarget type: {}\nPort: {}\nSub directory: {}\nFull url: {}",
59            self.scheme, self.target, self.target_type, self.port, self.subdirectory, self.full_url
60        )
61    }
62}
63
64/// Represents the scheme of a URL (`http` or `https`).
65#[derive(Debug, PartialEq)]
66pub enum Scheme {
67    Http,
68    Https,
69}
70
71impl Display for Scheme {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            Self::Http => write!(f, "http"),
75            Self::Https => write!(f, "https"),
76        }
77    }
78}
79
80/// Represents the type of the host/target.
81///
82/// Can be:
83/// - `Dns`
84/// - `IPv4`
85/// - `IPv6`
86#[derive(Debug, PartialEq)]
87pub enum TargetType {
88    Dns,
89    IPv4,
90    IPv6,
91}
92
93impl Display for TargetType {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            Self::Dns => write!(f, "dns"),
97            Self::IPv4 => write!(f, "ipv4"),
98            Self::IPv6 => write!(f, "ipv6"),
99        }
100    }
101}
102
103impl TargetType {
104    /// Checks if the provided string is a valid DNS.
105    ///
106    /// # Rules
107    /// - Maximum length: 253 characters
108    /// - Each label ≤ 63 characters
109    /// - Cannot start or end with `-`
110    /// - Only ASCII alphanumeric characters and `-` allowed
111    pub fn is_dns(target: &str) -> Result<TargetType, UrlParserErrors> {
112        if target.len() > 253 {
113            return Err(UrlParserErrors::InvalidTargetType);
114        }
115
116        let valid: bool = target.split('.').all(|label| {
117            if label.is_empty() || label.len() > 63 {
118                return false;
119            }
120
121            if label.starts_with('-') || label.ends_with('-') {
122                return false;
123            }
124
125            if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
126                return false;
127            }
128
129            true
130        });
131
132        if valid {
133            Ok(TargetType::Dns)
134        } else {
135            Err(UrlParserErrors::InvalidTargetType)
136        }
137    }
138
139    /// Checks if the provided string is a valid IPv4 address.
140    pub fn is_ipv4(target: &str) -> Result<TargetType, UrlParserErrors> {
141        match Ipv4Addr::from_str(target) {
142            Ok(_) => Ok(TargetType::IPv4),
143            Err(_) => Err(UrlParserErrors::InvalidTargetType),
144        }
145    }
146
147    /// Checks if the provided string is a valid IPv6 address.
148    pub fn is_ipv6(target: &str) -> Result<TargetType, UrlParserErrors> {
149        let clean_ip = target.trim_matches(['[', ']'].as_ref());
150        match Ipv6Addr::from_str(clean_ip) {
151            Ok(_) => Ok(TargetType::IPv6),
152            Err(_) => Err(UrlParserErrors::InvalidTargetType),
153        }
154    }
155}
156
157/// Represents possible errors when parsing a URL.
158#[derive(Debug)]
159pub enum UrlParserErrors {
160    UrlEmpty,
161    InvalidSize,
162    InvalidScheme,
163    InvalidTargetType,
164    InvalidSchemeSyntax,
165    InvalidPort,
166}
167
168impl Display for UrlParserErrors {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match self {
171            Self::UrlEmpty => {
172                write!(f, "The url is empty")
173            }
174            Self::InvalidSize => {
175                write!(f, "Invalid size!")
176            }
177            Self::InvalidScheme => {
178                write!(f, "Invalid scheme => http or https")
179            }
180            Self::InvalidTargetType => {
181                write!(f, "Invalid target type => Must be a DNS or IPV4 OR IPV6")
182            }
183            Self::InvalidSchemeSyntax => {
184                write!(f, "Invalid scheme sintax => http:// or https://")
185            }
186            Self::InvalidPort => {
187                write!(f, "Invalid port => (1 -> 65,535)")
188            }
189        }
190    }
191}
192
193impl Error for UrlParserErrors {}
194
195/// Helper macro to safely slice a string with error handling.
196///
197/// subslice!({input}, {range}, {ErrorMessage})
198///
199/// # Example
200/// ```rust,ignore
201///
202/// let s = "<https://example.com>";
203/// let scheme = subslice!(s, ..5, UrlParserErrors::InvalidSize);
204/// ```
205#[macro_export]
206macro_rules! subslice {
207    ($s:expr, $slice:expr, $err:expr) => {
208        $s.get($slice).ok_or($err)?
209    };
210}
211
212impl std::str::FromStr for UrlParser {
213    type Err = UrlParserErrors;
214    fn from_str(s: &str) -> Result<Self, Self::Err> {
215        UrlParser::new(s)
216    }
217}
218
219impl TryFrom<&str> for UrlParser {
220    type Error = UrlParserErrors;
221    fn try_from(value: &str) -> Result<Self, Self::Error> {
222        UrlParser::new(value)
223    }
224}
225
226impl UrlParser {
227    /// Creates a new [`UrlParser`] from a `Terminal` input.
228    ///
229    /// # Errors
230    /// Returns [`UrlParserErrors`] if:
231    /// - The scheme is invalid
232    /// - The host is invalid
233    /// - The URL is empty
234    ///
235    /// # Example
236    ///  ```rust,no_run
237    /// use stalkermap::utils::UrlParser;
238    ///
239    /// // Safe creation via `new`
240    /// let url = UrlParser::new("<http://example.com>").unwrap();
241    /// assert_eq!(url.full_url, "<http://example.com>");
242    ///
243    /// // Also usable via `parse` (FromStr) or `TryFrom`
244    /// let url2: UrlParser = "<http://example.com>".parse().unwrap();
245    /// let url3 = UrlParser::try_from("<http://example.com>").unwrap();
246    /// ```
247    pub fn new(input_url: &str) -> Result<UrlParser, UrlParserErrors> {
248        let url = input_url;
249
250        if url.is_empty() {
251            return Err(UrlParserErrors::UrlEmpty);
252        }
253
254        if subslice!(&url, ..7, UrlParserErrors::InvalidSize) != "http://"
255            && subslice!(&url, ..8, UrlParserErrors::InvalidSize) != "https://"
256        {
257            return Err(UrlParserErrors::InvalidSchemeSyntax);
258        }
259
260        let scheme = if url.starts_with("http://") {
261            Scheme::Http
262        } else if url.starts_with("https://") {
263            Scheme::Https
264        } else {
265            return Err(UrlParserErrors::InvalidScheme);
266        };
267
268        let target: String = {
269            match scheme {
270                Scheme::Http => {
271                    if url[7..].starts_with('[') {
272                        url[7..]
273                            .chars()
274                            .take_while(|c| *c != ']')
275                            .chain(std::iter::once(']'))
276                            .collect()
277                    } else {
278                        url.chars()
279                            .skip(7)
280                            .take_while(|c| *c != ':' && *c != '/')
281                            .collect()
282                    }
283                }
284                Scheme::Https => {
285                    if url[8..].starts_with('[') {
286                        url[8..]
287                            .chars()
288                            .take_while(|c| *c != ']')
289                            .chain(std::iter::once(']'))
290                            .collect()
291                    } else {
292                        url.chars()
293                            .skip(8)
294                            .take_while(|c| *c != ':' && *c != '/')
295                            .collect()
296                    }
297                }
298            }
299        };
300
301        let target_type: TargetType = TargetType::is_ipv4(&target)
302            .or(TargetType::is_ipv6(&target))
303            .or(TargetType::is_dns(&target))?;
304
305        let quant_to_skip = match scheme {
306            Scheme::Http => "http://".len() + target.len(),
307            Scheme::Https => "https://".len() + target.len(),
308        };
309
310        let port: u16 = {
311            if quant_to_skip >= url.len() {
312                0
313            } else if url
314                .chars()
315                .nth(quant_to_skip)
316                .ok_or(UrlParserErrors::InvalidSize)?
317                == ':'
318            {
319                let string_port_temp: String = url
320                    .chars()
321                    .skip(quant_to_skip + 1)
322                    .take_while(|v| v.is_ascii_digit())
323                    .collect();
324
325                match string_port_temp.parse::<u16>() {
326                    Ok(port) => port,
327                    Err(_) => return Err(UrlParserErrors::InvalidPort),
328                }
329            } else {
330                0
331            }
332        };
333
334        let subdirectory = {
335            let chars_to_skip = {
336                match scheme {
337                    Scheme::Http => "http://".len(),
338                    Scheme::Https => "https://".len(),
339                }
340            } + target.len()
341                + {
342                    match port {
343                        0 => 0,
344                        n => format!(":{}", n).len(),
345                    }
346                };
347            let subdirectory: String = url.chars().skip(chars_to_skip).collect();
348            subdirectory
349        };
350
351        let full_url = format!(
352            "{}://{}{}{}",
353            scheme,
354            target,
355            match port {
356                0 => String::new(),
357                n => format!(":{}", n),
358            },
359            subdirectory
360        );
361
362        Ok(UrlParser {
363            scheme,
364            target,
365            target_type,
366            port,
367            subdirectory,
368            full_url,
369        })
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_url_urlparser_valid_http_dns() {
379        let url = UrlParser::new("http://example.com").unwrap();
380        assert_eq!(format!("{}", url.scheme), "http");
381        assert_eq!(url.target, "example.com");
382        assert_eq!(format!("{}", url.target_type), "dns");
383        assert_eq!(url.port, 0);
384        assert_eq!(url.subdirectory, "");
385        assert_eq!(url.full_url, "http://example.com");
386        assert_ne!(
387            url.to_string(),
388            "Scheme: http Target: example.com Target type: dns Port: 0 Sub directory: Full url: http://example.com"
389        );
390    }
391
392    #[test]
393    fn test_url_urlparser_valid_https_dns_with_path() {
394        let url = UrlParser::new("https://example.com/test/path").unwrap();
395        assert_eq!(format!("{}", url.scheme), "https");
396        assert_eq!(url.target, "example.com");
397        assert_eq!(format!("{}", url.target_type), "dns");
398        assert_eq!(url.port, 0);
399        assert_eq!(url.subdirectory, "/test/path");
400        assert_eq!(url.full_url, "https://example.com/test/path");
401        assert_ne!(
402            url.to_string(),
403            "Scheme: https Target: example.com Target type: dns Port: 0 Sub directory: /test/path Full url: https://example.com/test/path"
404        );
405    }
406
407    #[test]
408    fn test_url_urlparser_valid_https_dns_with_port_and_path() {
409        let url = UrlParser::new("https://example.com:420/test/path").unwrap();
410        assert_eq!(format!("{}", url.scheme), "https");
411        assert_eq!(url.target, "example.com");
412        assert_eq!(format!("{}", url.target_type), "dns");
413        assert_eq!(url.port, 420);
414        assert_eq!(url.subdirectory, "/test/path");
415        assert_eq!(url.full_url, "https://example.com:420/test/path");
416        assert_ne!(
417            url.to_string(),
418            "Scheme: https Target: example.com Target type: dns Port: 420 Sub directory: /test/path Full url: https://example.com:420/test/path"
419        );
420    }
421
422    #[test]
423    fn test_url_urlparser_valid_http_with_port() {
424        let url = UrlParser::new("http://localhost:8080").unwrap();
425        assert_eq!(format!("{}", url.scheme), "http");
426        assert_eq!(url.target, "localhost");
427        assert_eq!(format!("{}", url.target_type), "dns");
428        assert_eq!(url.port, 8080);
429        assert_eq!(url.subdirectory, "");
430        assert_eq!(url.full_url, "http://localhost:8080");
431        assert_ne!(
432            url.to_string(),
433            "Scheme: http Target: localhost Target type: dns Port: 8080 Sub directory: Full url: http://localhost:8080"
434        );
435    }
436
437    #[test]
438    fn test_url_urlparser_valid_ipv4() {
439        let url = UrlParser::new("http://127.0.0.1").unwrap();
440        assert_eq!(format!("{}", url.scheme), "http");
441        assert_eq!(url.target, "127.0.0.1");
442        assert_eq!(format!("{}", url.target_type), "ipv4");
443        assert_eq!(url.port, 0);
444        assert_eq!(url.subdirectory, "");
445        assert_eq!(url.full_url, "http://127.0.0.1");
446        assert_ne!(
447            url.to_string(),
448            "Scheme: http Target: 127.0.0.1 Target type: ipv4 Port: 0 Sub directory: Full url: http://127.0.0.1"
449        );
450    }
451
452    #[test]
453    fn test_url_urlparser_valid_ipv6() {
454        let url = UrlParser::new("https://[::1]").unwrap();
455        assert_eq!(format!("{}", url.scheme), "https");
456        assert_eq!(url.target, "[::1]");
457        assert_eq!(format!("{}", url.target_type), "ipv6");
458        assert_eq!(url.port, 0);
459        assert_eq!(url.subdirectory, "");
460        assert_eq!(url.full_url, "https://[::1]");
461        assert_ne!(
462            url.to_string(),
463            "Scheme: https Target: [::1] Target type: ipv6 Port: 0 Sub directory: Full url: https://[::1]"
464        );
465    }
466
467    #[test]
468    fn test_url_urlparser_direct_parse() {
469        let url = "https://example.com:33".parse::<UrlParser>().unwrap();
470        assert_eq!(format!("{}", url.scheme), "https");
471        assert_eq!(url.target, "example.com");
472        assert_eq!(format!("{}", url.target_type), "dns");
473        assert_eq!(url.port, 33);
474        assert_eq!(url.subdirectory, "");
475        assert_eq!(url.full_url, "https://example.com:33");
476        assert_ne!(
477            url.to_string(),
478            "Scheme: https Target: example.com Target type: dns Port: 33 Sub directory: Full url: https://example.com:33"
479        );
480    }
481
482    #[test]
483    fn test_url_urlparser_direct_parse_try_from() {
484        let url = UrlParser::try_from("http://example.com").unwrap();
485        assert_eq!(format!("{}", url.scheme), "http");
486        assert_eq!(url.target, "example.com");
487        assert_eq!(format!("{}", url.target_type), "dns");
488        assert_eq!(url.port, 0);
489        assert_eq!(url.subdirectory, "");
490        assert_eq!(url.full_url, "http://example.com");
491        assert_ne!(
492            url.to_string(),
493            "Scheme: http Target: example.com Target type: dns Port: 0 Sub directory: Full url: https://example.com"
494        );
495    }
496
497    #[test]
498    fn test_url_urlparser_invalid_empty_url() {
499        let res = UrlParser::new("");
500        assert!(matches!(res, Err(UrlParserErrors::UrlEmpty)));
501    }
502
503    #[test]
504    fn test_url_urlparser_invalid_scheme() {
505        let res = UrlParser::new("ftp://example.com");
506        assert!(matches!(res, Err(UrlParserErrors::InvalidSchemeSyntax)));
507    }
508
509    #[test]
510    fn test_url_urlparser_invalid_dns() {
511        let res = UrlParser::new("http://exa$mple.com");
512        assert!(matches!(res, Err(UrlParserErrors::InvalidTargetType)));
513    }
514
515    #[test]
516    fn test_url_urlparser_invalid_port_not_number() {
517        let res = UrlParser::new("http://example.com:abcd");
518        assert!(matches!(res, Err(UrlParserErrors::InvalidPort)));
519    }
520
521    #[test]
522    fn test_url_urlparser_invalid_port_out_of_range() {
523        let res = UrlParser::new("http://example.com:70000");
524        assert!(matches!(res, Err(UrlParserErrors::InvalidPort)));
525    }
526}