subscription_proxy_pool/
node.rs1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use url::Url;
6
7use crate::{Error, Result};
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ProxyKind {
13 Http,
15 Https,
17 Socks5,
19 Socks5h,
21}
22
23impl ProxyKind {
24 fn scheme(self) -> &'static str {
25 match self {
26 Self::Http => "http",
27 Self::Https => "https",
28 Self::Socks5 => "socks5",
29 Self::Socks5h => "socks5h",
30 }
31 }
32}
33
34#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct ProxyNode {
40 name: String,
41 endpoint: String,
42}
43
44impl ProxyNode {
45 pub fn from_url(value: &str) -> Result<Self> {
47 let mut url = Url::parse(value).map_err(|_| Error::Config("invalid proxy URL"))?;
48 Self::validate_url(&url)?;
49 let name = url.fragment().unwrap_or("proxy").to_owned();
50 url.set_fragment(None);
51 Ok(Self {
52 name,
53 endpoint: url.into(),
54 })
55 }
56
57 fn validate_url(url: &Url) -> Result<()> {
58 if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h") {
59 return Err(Error::Config("unsupported proxy protocol"));
60 }
61 if url.host_str().is_none_or(str::is_empty) || url.port_or_known_default().unwrap_or(0) == 0
62 {
63 return Err(Error::Config("proxy host and a nonzero port are required"));
64 }
65 if !matches!(url.path(), "" | "/") || url.query().is_some() {
66 return Err(Error::Config("proxy URLs cannot contain a path or query"));
67 }
68 Ok(())
69 }
70
71 pub fn validate(&self) -> Result<()> {
73 let url = Url::parse(&self.endpoint).map_err(|_| Error::Config("invalid proxy URL"))?;
74 Self::validate_url(&url)?;
75 if url.fragment().is_some() {
76 return Err(Error::Config("stored proxy URL cannot contain a fragment"));
77 }
78 Ok(())
79 }
80
81 pub fn with_name(mut self, name: impl Into<String>) -> Self {
83 self.name = name.into();
84 self
85 }
86
87 pub fn name(&self) -> &str {
89 &self.name
90 }
91
92 pub fn id(&self) -> String {
94 format!("{:x}", Sha256::digest(self.endpoint.as_bytes()))
95 }
96
97 pub fn url(&self) -> &str {
99 &self.endpoint
100 }
101
102 pub fn kind(&self) -> ProxyKind {
104 match self.endpoint.split(':').next() {
105 Some("https") => ProxyKind::Https,
106 Some("socks5") => ProxyKind::Socks5,
107 Some("socks5h") => ProxyKind::Socks5h,
108 _ => ProxyKind::Http,
109 }
110 }
111}
112
113impl fmt::Debug for ProxyNode {
114 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115 formatter
116 .debug_struct("ProxyNode")
117 .field("id", &self.id())
118 .field("protocol", &self.kind().scheme())
119 .finish_non_exhaustive()
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn identity_ignores_name_but_includes_credentials() {
129 let first = ProxyNode::from_url("http://user:secret@localhost:8080#one").unwrap();
130 let renamed = first.clone().with_name("two");
131 assert_eq!(first.id(), renamed.id());
132 assert_ne!(
133 first.id(),
134 ProxyNode::from_url("http://user:changed@localhost:8080")
135 .unwrap()
136 .id()
137 );
138 assert!(!format!("{first:?}").contains("secret"));
139 }
140
141 #[test]
142 fn validates_protocol_ports_and_endpoint_shape() {
143 for value in [
144 "ss://host:10",
145 "socks5://host",
146 "http://host:0",
147 "http://host/path",
148 "http://host?key=secret",
149 ] {
150 assert!(ProxyNode::from_url(value).is_err(), "{value}");
151 }
152 for value in [
153 "http://localhost",
154 "https://localhost",
155 "socks5h://[::1]:1080",
156 ] {
157 assert!(ProxyNode::from_url(value).is_ok(), "{value}");
158 }
159 }
160}