1use std::time::Duration;
2
3use reqwest::{Method, header};
4use serde_json::Value;
5use url::Url;
6
7use super::{
8 SubscriptionDevice, SubscriptionDocument, SubscriptionInfo, SubscriptionJson,
9 SubscriptionMetadata, SubscriptionResponse,
10};
11use crate::{
12 Error, ProxyConfig, Result, SubscriptionSettings,
13 transport::{self, ErrorUrlPolicy},
14};
15
16const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
17const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
18const DEFAULT_USER_AGENT: &str = concat!("xui-rs/", env!("CARGO_PKG_VERSION"));
19
20pub const DEFAULT_SUBSCRIPTION_RESPONSE_BODY_LIMIT: usize = 64 * 1024 * 1024;
22
23#[must_use]
25pub struct SubscriptionClientBuilder {
26 base_url: Url,
27 raw_path: String,
28 json_path: String,
29 clash_path: String,
30 timeout: Duration,
31 connect_timeout: Duration,
32 accept_invalid_certs: bool,
33 proxy: Option<ProxyConfig>,
34 response_body_limit: usize,
35 device: Option<SubscriptionDevice>,
36}
37
38impl SubscriptionClientBuilder {
39 fn new(base_url: impl AsRef<str>) -> Result<Self> {
40 Ok(Self {
41 base_url: normalize_server_url(base_url.as_ref())?,
42 raw_path: "sub/".to_owned(),
43 json_path: "json/".to_owned(),
44 clash_path: "clash/".to_owned(),
45 timeout: DEFAULT_TIMEOUT,
46 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
47 accept_invalid_certs: false,
48 proxy: None,
49 response_body_limit: DEFAULT_SUBSCRIPTION_RESPONSE_BODY_LIMIT,
50 device: None,
51 })
52 }
53
54 pub fn raw_path(mut self, path: impl Into<String>) -> Self {
56 self.raw_path = path.into();
57 self
58 }
59
60 pub fn json_path(mut self, path: impl Into<String>) -> Self {
62 self.json_path = path.into();
63 self
64 }
65
66 pub fn clash_path(mut self, path: impl Into<String>) -> Self {
68 self.clash_path = path.into();
69 self
70 }
71
72 pub const fn timeout(mut self, timeout: Duration) -> Self {
74 self.timeout = timeout;
75 self
76 }
77
78 pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
80 self.connect_timeout = timeout;
81 self
82 }
83
84 pub const fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
89 self.accept_invalid_certs = accept;
90 self
91 }
92
93 pub fn proxy(mut self, proxy: ProxyConfig) -> Self {
97 self.proxy = Some(proxy);
98 self
99 }
100
101 pub fn proxy_url(self, proxy_url: impl AsRef<str>) -> Result<Self> {
107 Ok(self.proxy(ProxyConfig::new(proxy_url)?))
108 }
109
110 pub fn no_proxy(mut self) -> Self {
112 self.proxy = None;
113 self
114 }
115
116 pub const fn response_body_limit(mut self, limit: usize) -> Self {
121 self.response_body_limit = limit;
122 self
123 }
124
125 pub fn device(mut self, device: SubscriptionDevice) -> Self {
127 self.device = Some(device);
128 self
129 }
130
131 pub fn no_device(mut self) -> Self {
133 self.device = None;
134 self
135 }
136
137 pub fn build(self) -> Result<SubscriptionClient> {
144 let raw_prefix = prefix_from_path(&self.base_url, &self.raw_path, "raw")?;
145 let json_prefix = prefix_from_path(&self.base_url, &self.json_path, "JSON")?;
146 let clash_prefix = prefix_from_path(&self.base_url, &self.clash_path, "Clash")?;
147 SubscriptionClient::from_parts(
148 &self.base_url,
149 [&self.raw_path, &self.json_path, &self.clash_path],
150 raw_prefix,
151 json_prefix,
152 clash_prefix,
153 SubscriptionTransportConfig {
154 timeout: self.timeout,
155 connect_timeout: self.connect_timeout,
156 accept_invalid_certs: self.accept_invalid_certs,
157 proxy: self.proxy.as_ref(),
158 response_body_limit: self.response_body_limit,
159 device: self.device.as_ref(),
160 },
161 )
162 }
163}
164
165impl std::fmt::Debug for SubscriptionClientBuilder {
166 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 formatter
168 .debug_struct("SubscriptionClientBuilder")
169 .field("server_origin", &origin(&self.base_url))
170 .field("raw_path", &"[REDACTED]")
171 .field("json_path", &"[REDACTED]")
172 .field("clash_path", &"[REDACTED]")
173 .field("timeout", &self.timeout)
174 .field("connect_timeout", &self.connect_timeout)
175 .field("accept_invalid_certs", &self.accept_invalid_certs)
176 .field("proxy", &self.proxy.as_ref().map(ProxyConfig::scheme))
177 .field("response_body_limit", &self.response_body_limit)
178 .field("device", &self.device)
179 .finish()
180 }
181}
182
183#[derive(Clone)]
189pub struct SubscriptionClient {
190 http: reqwest::Client,
191 raw_prefix: Url,
192 json_prefix: Url,
193 clash_prefix: Url,
194 response_body_limit: usize,
195 mihomo_prefix: Option<Url>,
196 legacy_prefix: Option<Url>,
197}
198
199impl SubscriptionClient {
200 pub fn new(server_url: impl AsRef<str>) -> Result<Self> {
207 Self::builder(server_url)?.build()
208 }
209
210 pub fn builder(server_url: impl AsRef<str>) -> Result<SubscriptionClientBuilder> {
216 SubscriptionClientBuilder::new(server_url)
217 }
218
219 pub fn from_settings(settings: &SubscriptionSettings) -> Result<Self> {
227 let raw_prefix = normalize_prefix_uri(&settings.sub_uri, "subURI")?;
228 let server_url = origin_url(&raw_prefix);
229 let json_prefix = if settings.sub_json_uri.trim().is_empty() {
230 prefix_from_path(&server_url, &settings.sub_json_path, "JSON")?
231 } else {
232 normalize_prefix_uri(&settings.sub_json_uri, "subJsonURI")?
233 };
234 let clash_prefix = if settings.sub_clash_uri.trim().is_empty() {
235 prefix_from_path(&server_url, &settings.sub_clash_path, "Clash")?
236 } else {
237 normalize_prefix_uri(&settings.sub_clash_uri, "subClashURI")?
238 };
239 Self::from_parts(
240 &origin_url(&clash_prefix),
241 [
242 &settings.sub_path,
243 &settings.sub_json_path,
244 &settings.sub_clash_path,
245 ],
246 raw_prefix,
247 json_prefix,
248 clash_prefix,
249 SubscriptionTransportConfig::default(),
250 )
251 }
252
253 fn from_parts(
261 alias_base: &Url,
262 configured_paths: [&str; 3],
263 raw_prefix: Url,
264 json_prefix: Url,
265 clash_prefix: Url,
266 transport: SubscriptionTransportConfig<'_>,
267 ) -> Result<Self> {
268 let mut default_headers = header::HeaderMap::new();
269 let user_agent = if let Some(device) = transport.device {
270 if device.hwid().len() < 6 {
271 return Err(Error::Configuration(
272 "subscription device HWID must contain at least 6 bytes".to_owned(),
273 ));
274 }
275 insert_device_header(&mut default_headers, "x-hwid", device.hwid(), "HWID")?;
276 insert_device_header(
277 &mut default_headers,
278 "x-device-os",
279 &device.device_os,
280 "device OS",
281 )?;
282 insert_device_header(
283 &mut default_headers,
284 "x-ver-os",
285 &device.os_version,
286 "OS version",
287 )?;
288 insert_device_header(
289 &mut default_headers,
290 "x-device-model",
291 &device.device_model,
292 "device model",
293 )?;
294 if device.user_agent.is_empty() {
295 DEFAULT_USER_AGENT
296 } else {
297 device.user_agent.as_str()
298 }
299 } else {
300 DEFAULT_USER_AGENT
301 };
302 let mut http_builder = reqwest::Client::builder()
303 .no_proxy()
304 .default_headers(default_headers)
305 .timeout(transport.timeout)
306 .connect_timeout(transport.connect_timeout)
307 .user_agent(user_agent)
308 .danger_accept_invalid_certs(transport.accept_invalid_certs);
309 if let Some(proxy) = transport.proxy {
310 http_builder = http_builder.proxy(proxy.reqwest_proxy()?);
311 }
312 let http = http_builder
313 .build()
314 .map_err(|error| Error::Configuration(error.to_string()))?;
315 let prefixes = [&raw_prefix, &json_prefix, &clash_prefix];
316 let alias_prefix = |path: &str, label: &str, owner_count: usize| -> Result<Option<Url>> {
317 let candidate = prefix_from_path(alias_base, path, label)?;
318 let shadowed = prefixes[..owner_count].iter().any(|prefix| {
319 prefix.path().trim_matches('/') == candidate.path().trim_matches('/')
320 }) || configured_paths[..owner_count]
321 .iter()
322 .any(|configured| configured.trim().trim_matches('/') == path.trim_matches('/'));
323 Ok((!shadowed).then_some(candidate))
324 };
325 let mihomo_prefix = alias_prefix("mihomo/", "Mihomo", 2)?;
328 let legacy_prefix = alias_prefix("clash-legacy/", "legacy Clash", 3)?;
329 Ok(Self {
330 http,
331 mihomo_prefix,
332 legacy_prefix,
333 raw_prefix,
334 json_prefix,
335 clash_prefix,
336 response_body_limit: transport.response_body_limit,
337 })
338 }
339
340 pub const fn response_body_limit(&self) -> usize {
342 self.response_body_limit
343 }
344
345 pub async fn raw(
353 &self,
354 subscription_id: &str,
355 ) -> Result<SubscriptionResponse<SubscriptionDocument>> {
356 self.text(
357 Method::GET,
358 Format::Raw,
359 subscription_id,
360 None,
361 "text/plain",
362 )
363 .await
364 }
365
366 pub async fn html(
373 &self,
374 subscription_id: &str,
375 ) -> Result<SubscriptionResponse<SubscriptionDocument>> {
376 self.text(Method::GET, Format::Raw, subscription_id, None, "text/html")
377 .await
378 }
379
380 pub async fn info(&self, subscription_id: &str) -> Result<SubscriptionInfo> {
387 Ok(self.info_with_metadata(subscription_id).await?.content)
388 }
389
390 pub async fn info_with_metadata(
398 &self,
399 subscription_id: &str,
400 ) -> Result<SubscriptionResponse<SubscriptionInfo>> {
401 let (headers, bytes, method, redacted_url) = self
402 .send(
403 Method::GET,
404 Format::Raw,
405 subscription_id,
406 Some(("format", "info")),
407 "application/json",
408 )
409 .await?;
410 let content = serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
411 method,
412 url: Box::new(redacted_url),
413 source,
414 })?;
415 Ok(SubscriptionResponse {
416 content,
417 metadata: SubscriptionMetadata::from_headers(&headers),
418 })
419 }
420
421 pub async fn json(
430 &self,
431 subscription_id: &str,
432 ) -> Result<SubscriptionResponse<SubscriptionJson>> {
433 let (headers, bytes, method, redacted_url) = self
434 .send(
435 Method::GET,
436 Format::Json,
437 subscription_id,
438 Some(("view", "raw")),
439 "application/json",
440 )
441 .await?;
442 let value: Value = serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
443 method,
444 url: Box::new(redacted_url),
445 source,
446 })?;
447 Ok(SubscriptionResponse {
448 content: SubscriptionJson::new(value),
449 metadata: SubscriptionMetadata::from_headers(&headers),
450 })
451 }
452
453 pub async fn clash(
462 &self,
463 subscription_id: &str,
464 ) -> Result<SubscriptionResponse<SubscriptionDocument>> {
465 self.text(
466 Method::GET,
467 Format::Clash,
468 subscription_id,
469 Some(("view", "raw")),
470 "application/yaml",
471 )
472 .await
473 }
474
475 pub async fn mihomo(
482 &self,
483 subscription_id: &str,
484 ) -> Result<SubscriptionResponse<SubscriptionDocument>> {
485 self.text(
486 Method::GET,
487 Format::Mihomo,
488 subscription_id,
489 Some(("view", "raw")),
490 "application/yaml",
491 )
492 .await
493 }
494
495 pub async fn mihomo_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
502 self.head(Format::Mihomo, subscription_id).await
503 }
504
505 pub async fn clash_legacy(
512 &self,
513 subscription_id: &str,
514 ) -> Result<SubscriptionResponse<SubscriptionDocument>> {
515 self.text(
516 Method::GET,
517 Format::Legacy,
518 subscription_id,
519 Some(("view", "raw")),
520 "application/yaml",
521 )
522 .await
523 }
524
525 pub async fn clash_legacy_metadata(
532 &self,
533 subscription_id: &str,
534 ) -> Result<SubscriptionMetadata> {
535 self.head(Format::Legacy, subscription_id).await
536 }
537
538 pub async fn hwid_status(&self, subscription_id: &str) -> Result<super::HwidSlotStatus> {
544 let (_, bytes, method, url) = self
545 .send(
546 Method::GET,
547 Format::HwidStatus,
548 subscription_id,
549 None,
550 "application/json",
551 )
552 .await?;
553 serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
554 method,
555 url: Box::new(url),
556 source,
557 })
558 }
559
560 pub async fn hwid_status_metadata(
566 &self,
567 subscription_id: &str,
568 ) -> Result<SubscriptionMetadata> {
569 self.head(Format::HwidStatus, subscription_id).await
570 }
571
572 pub async fn raw_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
578 self.head(Format::Raw, subscription_id).await
579 }
580
581 pub async fn json_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
587 self.head(Format::Json, subscription_id).await
588 }
589
590 pub async fn clash_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
596 self.head(Format::Clash, subscription_id).await
597 }
598
599 async fn head(&self, format: Format, subscription_id: &str) -> Result<SubscriptionMetadata> {
600 let (headers, _, _, _) = self
601 .send(Method::HEAD, format, subscription_id, None, "*/*")
602 .await?;
603 Ok(SubscriptionMetadata::from_headers(&headers))
604 }
605
606 async fn text(
607 &self,
608 method: Method,
609 format: Format,
610 subscription_id: &str,
611 query: Option<(&str, &str)>,
612 accept: &str,
613 ) -> Result<SubscriptionResponse<SubscriptionDocument>> {
614 let (headers, bytes, method, redacted_url) = self
615 .send(method, format, subscription_id, query, accept)
616 .await?;
617 let text = String::from_utf8(bytes).map_err(|source| Error::Utf8 {
618 method,
619 url: Box::new(redacted_url),
620 source,
621 })?;
622 Ok(SubscriptionResponse {
623 content: SubscriptionDocument::new(text),
624 metadata: SubscriptionMetadata::from_headers(&headers),
625 })
626 }
627
628 async fn send(
636 &self,
637 method: Method,
638 format: Format,
639 subscription_id: &str,
640 query: Option<(&str, &str)>,
641 accept: &str,
642 ) -> Result<(header::HeaderMap, Vec<u8>, Method, Url)> {
643 let prefix = match format {
644 Format::Raw | Format::HwidStatus => Some(&self.raw_prefix),
645 Format::Mihomo => self.mihomo_prefix.as_ref(),
646 Format::Legacy => self.legacy_prefix.as_ref(),
647 Format::Json => Some(&self.json_prefix),
648 Format::Clash => Some(&self.clash_prefix),
649 }
650 .ok_or_else(|| {
651 Error::Configuration(
652 "subscription alias is shadowed by a configured subscription path".into(),
653 )
654 })?;
655 let (mut url, mut redacted_url) = endpoint(prefix, subscription_id)?;
656 if matches!(format, Format::HwidStatus) {
657 url.path_segments_mut()
658 .expect("validated HTTP URL")
659 .push("hwid-status");
660 redacted_url
661 .path_segments_mut()
662 .expect("validated HTTP URL")
663 .push("hwid-status");
664 }
665 if let Some((key, value)) = query {
666 url.query_pairs_mut().append_pair(key, value);
667 redacted_url.query_pairs_mut().append_pair(key, value);
668 }
669 let response = transport::send_request(
670 self.http
671 .request(method.clone(), url)
672 .header(header::ACCEPT, accept),
673 &method,
674 &redacted_url,
675 ErrorUrlPolicy::Redact,
676 )
677 .await?;
678 let status = response.status();
679 if !status.is_success() {
680 return Err(Error::HttpStatus {
681 method,
682 url: Box::new(redacted_url),
683 status,
684 });
685 }
686 let headers = response.headers().clone();
687 let bytes = transport::read_response_body(
688 response,
689 &method,
690 &redacted_url,
691 self.response_body_limit,
692 ErrorUrlPolicy::Redact,
693 )
694 .await?;
695 Ok((headers, bytes, method, redacted_url))
696 }
697}
698
699#[derive(Clone, Copy)]
700struct SubscriptionTransportConfig<'proxy> {
701 timeout: Duration,
702 connect_timeout: Duration,
703 accept_invalid_certs: bool,
704 proxy: Option<&'proxy ProxyConfig>,
705 response_body_limit: usize,
706 device: Option<&'proxy SubscriptionDevice>,
707}
708
709impl Default for SubscriptionTransportConfig<'_> {
710 fn default() -> Self {
711 Self {
712 timeout: DEFAULT_TIMEOUT,
713 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
714 accept_invalid_certs: false,
715 proxy: None,
716 response_body_limit: DEFAULT_SUBSCRIPTION_RESPONSE_BODY_LIMIT,
717 device: None,
718 }
719 }
720}
721
722fn insert_device_header(
723 headers: &mut header::HeaderMap,
724 name: &'static str,
725 value: &str,
726 label: &str,
727) -> Result<()> {
728 if value.is_empty() {
729 return Ok(());
730 }
731 let value = header::HeaderValue::from_str(value)
732 .map_err(|error| Error::Configuration(format!("invalid {label} header: {error}")))?;
733 headers.insert(header::HeaderName::from_static(name), value);
734 Ok(())
735}
736
737impl std::fmt::Debug for SubscriptionClient {
738 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
739 formatter
740 .debug_struct("SubscriptionClient")
741 .field("server_origin", &origin(&self.raw_prefix))
742 .field("paths", &"[REDACTED]")
743 .field("response_body_limit", &self.response_body_limit)
744 .finish_non_exhaustive()
745 }
746}
747
748#[derive(Clone, Copy)]
749enum Format {
750 Raw,
751 Json,
752 Clash,
753 Mihomo,
754 Legacy,
755 HwidStatus,
756}
757
758fn normalize_server_url(value: &str) -> Result<Url> {
759 let mut url = parse_http_url(value, "subscription server URL")?;
760 url.set_query(None);
761 url.set_fragment(None);
762 if !url.path().ends_with('/') {
763 url.set_path(&format!("{}/", url.path()));
764 }
765 Ok(url)
766}
767
768fn normalize_prefix_uri(value: &str, field: &str) -> Result<Url> {
769 if value.trim().is_empty() {
770 return Err(Error::Configuration(format!("{field} must not be empty")));
771 }
772 let mut url = parse_http_url(value, field)?;
773 if url.query().is_some() || url.fragment().is_some() {
774 return Err(Error::Configuration(format!(
775 "{field} must not contain a query or fragment"
776 )));
777 }
778 if !url.path().ends_with('/') {
779 url.set_path(&format!("{}/", url.path()));
780 }
781 Ok(url)
782}
783
784fn parse_http_url(value: &str, field: &str) -> Result<Url> {
785 let url = Url::parse(value)
786 .map_err(|error| Error::Configuration(format!("invalid {field}: {error}")))?;
787 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
788 return Err(Error::Configuration(format!(
789 "{field} must be an absolute HTTP(S) URL"
790 )));
791 }
792 if !url.username().is_empty() || url.password().is_some() {
793 return Err(Error::Configuration(format!(
794 "{field} must not contain credentials"
795 )));
796 }
797 Ok(url)
798}
799
800fn prefix_from_path(base_url: &Url, path: &str, label: &str) -> Result<Url> {
801 let path = path.trim();
802 if path.is_empty() {
803 return Err(Error::Configuration(format!(
804 "{label} subscription path must not be empty"
805 )));
806 }
807 if path.contains('?') || path.contains('#') {
808 return Err(Error::Configuration(format!(
809 "{label} subscription path must not contain a query or fragment"
810 )));
811 }
812 let mut url = base_url
813 .join(path.trim_start_matches('/'))
814 .map_err(|error| Error::Configuration(format!("invalid {label} path: {error}")))?;
815 if !url.path().ends_with('/') {
816 url.set_path(&format!("{}/", url.path()));
817 }
818 Ok(url)
819}
820
821fn endpoint(prefix: &Url, subscription_id: &str) -> Result<(Url, Url)> {
822 if subscription_id.is_empty() {
823 return Err(Error::Configuration(
824 "subscription ID must not be empty".to_owned(),
825 ));
826 }
827 let mut url = prefix.clone();
828 url.path_segments_mut()
829 .map_err(|()| Error::Configuration("subscription URL cannot be a base".to_owned()))?
830 .pop_if_empty()
831 .push(subscription_id);
832 let mut redacted = prefix.clone();
833 redacted
834 .path_segments_mut()
835 .map_err(|()| Error::Configuration("subscription URL cannot be a base".to_owned()))?
836 .pop_if_empty()
837 .push("[REDACTED]");
838 Ok((url, redacted))
839}
840
841fn origin_url(url: &Url) -> Url {
842 let mut origin = url.clone();
843 origin.set_path("/");
844 origin.set_query(None);
845 origin.set_fragment(None);
846 origin
847}
848
849fn origin(url: &Url) -> String {
850 let mut value = origin_url(url);
851 value.set_path("");
852 value.to_string().trim_end_matches('/').to_owned()
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858
859 fn configuration_message(error: Error) -> String {
860 match error {
861 Error::Configuration(message) => message,
862 other => panic!("expected configuration error, got {other:?}"),
863 }
864 }
865
866 #[test]
867 fn builder_options_are_composable_and_debug_safe() {
868 let proxy = ProxyConfig::new("http://proxy.example:8080").unwrap();
869 let device = SubscriptionDevice::new("device-secret", "test-agent");
870 let builder = SubscriptionClient::builder("https://sub.example/root?ignored=yes#fragment")
871 .unwrap()
872 .raw_path("private/raw")
873 .json_path("private/json")
874 .clash_path("private/clash")
875 .timeout(Duration::from_secs(11))
876 .connect_timeout(Duration::from_secs(3))
877 .danger_accept_invalid_certs(true)
878 .response_body_limit(1234)
879 .proxy(proxy)
880 .no_proxy()
881 .device(device)
882 .no_device();
883
884 assert_eq!(builder.timeout, Duration::from_secs(11));
885 assert_eq!(builder.connect_timeout, Duration::from_secs(3));
886 assert!(builder.accept_invalid_certs);
887 assert_eq!(builder.response_body_limit, 1234);
888 assert!(builder.proxy.is_none());
889 assert!(builder.device.is_none());
890 assert!(builder.base_url.query().is_none());
891 assert!(builder.base_url.fragment().is_none());
892 assert_eq!(builder.base_url.path(), "/root/");
893
894 let debug = format!("{builder:?}");
895 assert!(debug.contains("https://sub.example"));
896 assert!(debug.contains("[REDACTED]"));
897 assert!(!debug.contains("private/raw"));
898
899 let client = builder.build().unwrap();
900 assert_eq!(client.response_body_limit(), 1234);
901 assert!(!format!("{client:?}").contains("private/raw"));
902 }
903
904 #[test]
905 fn proxy_url_and_device_headers_validate_early() {
906 let builder = SubscriptionClient::builder("https://sub.example")
907 .unwrap()
908 .proxy_url("socks5h://proxy.example:1080")
909 .unwrap();
910 assert_eq!(
911 builder.proxy.as_ref().unwrap().scheme(),
912 crate::ProxyScheme::Socks5h
913 );
914 assert!(builder.no_proxy().proxy.is_none());
915
916 let error = SubscriptionClient::builder("https://sub.example")
917 .unwrap()
918 .proxy_url("not a proxy")
919 .unwrap_err();
920 assert!(configuration_message(error).contains("invalid proxy URL"));
921
922 let short = SubscriptionDevice::new("short", "agent");
923 let error = SubscriptionClient::builder("https://sub.example")
924 .unwrap()
925 .device(short)
926 .build()
927 .unwrap_err();
928 assert!(configuration_message(error).contains("at least 6 bytes"));
929
930 let invalid = SubscriptionDevice::new("bad\nsecret", "agent");
931 let error = SubscriptionClient::builder("https://sub.example")
932 .unwrap()
933 .device(invalid)
934 .build()
935 .unwrap_err();
936 assert!(configuration_message(error).contains("invalid HWID header"));
937
938 let empty_optional_headers = SubscriptionDevice::new("device-secret", "");
939 SubscriptionClient::builder("https://sub.example")
940 .unwrap()
941 .device(empty_optional_headers)
942 .build()
943 .unwrap();
944 }
945
946 #[test]
947 fn subscription_urls_reject_ambiguous_or_credentialed_inputs() {
948 for input in [
949 "relative/path",
950 "ftp://sub.example",
951 "https://user:pass@sub.example",
952 ] {
953 assert!(SubscriptionClient::builder(input).is_err(), "{input}");
954 }
955
956 for raw_path in ["", "sub?format=raw", "sub#fragment"] {
957 let error = SubscriptionClient::builder("https://sub.example")
958 .unwrap()
959 .raw_path(raw_path)
960 .build()
961 .unwrap_err();
962 assert!(configuration_message(error).contains("raw subscription path"));
963 }
964
965 for value in [
966 "",
967 "https://sub.example/path?query=yes",
968 "https://sub.example/path#fragment",
969 ] {
970 let error = normalize_prefix_uri(value, "testURI").unwrap_err();
971 assert!(configuration_message(error).contains("testURI"));
972 }
973
974 let normalized = normalize_prefix_uri("https://sub.example/path", "testURI").unwrap();
975 assert_eq!(normalized.as_str(), "https://sub.example/path/");
976 assert_eq!(origin(&normalized), "https://sub.example");
977 }
978
979 #[test]
980 fn settings_require_valid_public_prefixes_and_fallback_paths() {
981 let empty = SubscriptionSettings::default();
982 assert!(SubscriptionClient::from_settings(&empty).is_err());
983
984 let invalid_json_path = SubscriptionSettings {
985 sub_uri: "https://sub.example/raw/".into(),
986 sub_json_path: "json?view=raw".into(),
987 sub_clash_path: "clash".into(),
988 ..SubscriptionSettings::default()
989 };
990 let error = SubscriptionClient::from_settings(&invalid_json_path).unwrap_err();
991 assert!(configuration_message(error).contains("JSON subscription path"));
992
993 let invalid_clash_uri = SubscriptionSettings {
994 sub_uri: "https://sub.example/raw/".into(),
995 sub_json_uri: "https://sub.example/json/".into(),
996 sub_clash_uri: "https://user:pass@sub.example/clash/".into(),
997 ..SubscriptionSettings::default()
998 };
999 let error = SubscriptionClient::from_settings(&invalid_clash_uri).unwrap_err();
1000 assert!(configuration_message(error).contains("subClashURI"));
1001 }
1002}