Skip to main content

xui_rs/subscription/
client.rs

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
20/// Default maximum public subscription response body size: 64 MiB.
21pub const DEFAULT_SUBSCRIPTION_RESPONSE_BODY_LIMIT: usize = 64 * 1024 * 1024;
22
23/// Builder for a public standalone [`SubscriptionClient`].
24#[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    /// Sets the configured raw subscription path (default `/sub/`).
55    pub fn raw_path(mut self, path: impl Into<String>) -> Self {
56        self.raw_path = path.into();
57        self
58    }
59
60    /// Sets the configured JSON subscription path (default `/json/`).
61    pub fn json_path(mut self, path: impl Into<String>) -> Self {
62        self.json_path = path.into();
63        self
64    }
65
66    /// Sets the configured Clash subscription path (default `/clash/`).
67    pub fn clash_path(mut self, path: impl Into<String>) -> Self {
68        self.clash_path = path.into();
69        self
70    }
71
72    /// Sets the total timeout for one subscription request.
73    pub const fn timeout(mut self, timeout: Duration) -> Self {
74        self.timeout = timeout;
75        self
76    }
77
78    /// Sets the timeout for establishing a connection.
79    pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
80        self.connect_timeout = timeout;
81        self
82    }
83
84    /// Accepts invalid TLS certificates for this subscription server.
85    ///
86    /// This weakens transport security for credential-bearing documents and
87    /// should only be used with an explicitly trusted self-signed deployment.
88    pub const fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
89        self.accept_invalid_certs = accept;
90        self
91    }
92
93    /// Routes subscription HTTP requests through an explicit proxy.
94    ///
95    /// Environment proxy variables are never consulted.
96    pub fn proxy(mut self, proxy: ProxyConfig) -> Self {
97        self.proxy = Some(proxy);
98        self
99    }
100
101    /// Parses and configures a credential-free proxy URL.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`Error::Configuration`] for an invalid proxy URL.
106    pub fn proxy_url(self, proxy_url: impl AsRef<str>) -> Result<Self> {
107        Ok(self.proxy(ProxyConfig::new(proxy_url)?))
108    }
109
110    /// Removes a previously configured explicit proxy.
111    pub fn no_proxy(mut self) -> Self {
112        self.proxy = None;
113        self
114    }
115
116    /// Sets the maximum in-memory size of a subscription response body.
117    ///
118    /// Both declared `Content-Length` and actually received chunked or
119    /// decompressed bytes are checked.
120    pub const fn response_body_limit(mut self, limit: usize) -> Self {
121        self.response_body_limit = limit;
122        self
123    }
124
125    /// Sends subscription-device headers used by 3x-ui's optional HWID gate.
126    pub fn device(mut self, device: SubscriptionDevice) -> Self {
127        self.device = Some(device);
128        self
129    }
130
131    /// Removes previously configured subscription-device metadata.
132    pub fn no_device(mut self) -> Self {
133        self.device = None;
134        self
135    }
136
137    /// Builds the standalone client.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error when a configured path or HTTP-client option is
142    /// invalid.
143    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/// Public client for the separate 3x-ui subscription HTTP/HTTPS server.
184///
185/// This client intentionally has no panel authentication or cookie state. The
186/// subscription identifier embedded into every request path is redacted from
187/// SDK errors.
188#[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    /// Creates a client using the default `/sub/`, `/json/`, and `/clash/`
201    /// paths below `server_url`.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error when the URL or HTTP-client configuration is invalid.
206    pub fn new(server_url: impl AsRef<str>) -> Result<Self> {
207        Self::builder(server_url)?.build()
208    }
209
210    /// Starts configuring a standalone subscription client.
211    ///
212    /// # Errors
213    ///
214    /// Returns an error when `server_url` is not an absolute HTTP(S) URL.
215    pub fn builder(server_url: impl AsRef<str>) -> Result<SubscriptionClientBuilder> {
216        SubscriptionClientBuilder::new(server_url)
217    }
218
219    /// Creates a client from the absolute public URIs and fallback paths in a
220    /// panel settings snapshot.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error when `subURI` is not an absolute HTTP(S) prefix or any
225    /// configured URI/HTTP option is invalid.
226    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    /// Builds an unauthenticated subscription transport from validated format prefixes.
254    /// Resolves fixed aliases below `alias_base` and marks conflicting aliases unavailable,
255    /// while allowing the configured Clash handler to serve Mihomo.
256    ///
257    /// # Errors
258    ///
259    /// Returns a configuration error for invalid device headers, alias URLs, or HTTP options.
260    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        // The configured Clash handler already serves Mihomo, but it cannot
326        // substitute for the distinct legacy renderer.
327        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    /// Returns the configured maximum subscription response body size.
341    pub const fn response_body_limit(&self) -> usize {
342        self.response_body_limit
343    }
344
345    /// Fetches the raw subscription body.
346    ///
347    /// The body is base64 when the panel's `subEncrypt` setting is enabled.
348    ///
349    /// # Errors
350    ///
351    /// Returns an error for transport or unsuccessful HTTP status.
352    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    /// Fetches the browser HTML information page.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error for transport, unsuccessful HTTP status, or invalid
371    /// UTF-8.
372    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    /// Fetches the typed `format=info` status view without imported link data.
381    ///
382    /// # Errors
383    ///
384    /// Returns an error for transport, unsuccessful HTTP status, or invalid
385    /// JSON.
386    pub async fn info(&self, subscription_id: &str) -> Result<SubscriptionInfo> {
387        Ok(self.info_with_metadata(subscription_id).await?.content)
388    }
389
390    /// Fetches the typed information view together with common and HWID headers.
391    ///
392    /// Prefer this method when device-limit state is relevant to the caller.
393    ///
394    /// # Errors
395    ///
396    /// Returns an error for transport, unsuccessful HTTP status, or invalid JSON.
397    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    /// Fetches and parses the Xray JSON subscription document.
422    ///
423    /// The raw-download view is requested to avoid browser content negotiation.
424    ///
425    /// # Errors
426    ///
427    /// Returns an error for transport, unsuccessful HTTP status, or invalid
428    /// JSON.
429    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    /// Fetches the Clash/Mihomo YAML subscription document.
454    ///
455    /// The raw-download view is requested to avoid browser content negotiation.
456    ///
457    /// # Errors
458    ///
459    /// Returns an error for transport, unsuccessful HTTP status, or invalid
460    /// UTF-8.
461    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    /// Fetches the Mihomo alias (`/mihomo/`), when enabled and not shadowed by a configured path.
476    ///
477    /// # Errors
478    ///
479    /// Returns a configuration error when the alias is shadowed, or an error
480    /// for transport, unsuccessful HTTP status, or invalid UTF-8.
481    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    /// Retrieves response headers from the mihomo alias.
496    ///
497    /// # Errors
498    ///
499    /// Returns a configuration error when the alias is shadowed, or an error
500    /// for transport or unsuccessful HTTP status.
501    pub async fn mihomo_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
502        self.head(Format::Mihomo, subscription_id).await
503    }
504
505    /// Fetches the legacy Clash alias (`/clash-legacy/`), when enabled and not shadowed by a configured path.
506    ///
507    /// # Errors
508    ///
509    /// Returns a configuration error when the alias is shadowed, or an error
510    /// for transport, unsuccessful HTTP status, or invalid UTF-8.
511    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    /// Retrieves response headers from the `clash_legacy` alias.
526    ///
527    /// # Errors
528    ///
529    /// Returns a configuration error when the alias is shadowed, or an error
530    /// for transport or unsuccessful HTTP status.
531    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    /// Reads device-slot counts without registering or touching a device.
539    ///
540    /// # Errors
541    ///
542    /// Returns an error for transport, unsuccessful HTTP status, or malformed JSON.
543    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    /// Checks the read-only HWID status route with HEAD.
561    ///
562    /// # Errors
563    ///
564    /// Returns an error for transport or unsuccessful HTTP status.
565    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    /// Executes `HEAD` against the raw subscription route.
573    ///
574    /// # Errors
575    ///
576    /// Returns an error for transport or unsuccessful HTTP status.
577    pub async fn raw_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
578        self.head(Format::Raw, subscription_id).await
579    }
580
581    /// Executes `HEAD` against the JSON subscription route.
582    ///
583    /// # Errors
584    ///
585    /// Returns an error for transport or unsuccessful HTTP status.
586    pub async fn json_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
587        self.head(Format::Json, subscription_id).await
588    }
589
590    /// Executes `HEAD` against the Clash subscription route.
591    ///
592    /// # Errors
593    ///
594    /// Returns an error for transport or unsuccessful HTTP status.
595    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    /// Requests one subscription format with a safely encoded identifier and bounded response body.
629    /// Returns response headers, bytes, the method, and a URL with the identifier redacted.
630    ///
631    /// # Errors
632    ///
633    /// Rejects unavailable aliases and empty identifiers before HTTP; propagates transport,
634    /// HTTP-status, and response-size errors without exposing the subscription identifier.
635    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}