Skip to main content

azul_layout/
http.rs

1//! Simple HTTP client module for downloading resources (language packs, etc.)
2//!
3//! Uses ureq for simple, blocking HTTP requests. Designed to be exposed via C API.
4
5use alloc::string::String;
6use alloc::vec::Vec;
7use alloc::format;
8use core::fmt;
9
10use azul_css::{AzString, U8Vec, impl_vec, impl_vec_clone, impl_vec_debug, impl_vec_partialeq, impl_vec_mut, impl_option, impl_option_inner};
11
12// ============================================================================
13// Error types (C-compatible, single field per variant)
14// ============================================================================
15
16/// HTTP status error (4xx, 5xx responses)
17#[derive(Debug, Clone, PartialEq, Eq)]
18#[repr(C)]
19pub struct HttpStatusError {
20    /// HTTP status code
21    pub status_code: u16,
22    /// Status message
23    pub message: AzString,
24}
25
26/// Response too large error
27#[derive(Copy, Debug, Clone, PartialEq, Eq)]
28#[repr(C)]
29pub struct HttpResponseTooLargeError {
30    /// Maximum allowed size in bytes
31    pub max_size: u64,
32    /// Actual size in bytes
33    pub actual_size: u64,
34}
35
36/// HTTP error types (C-compatible)
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[repr(C, u8)]
39pub enum HttpError {
40    /// Invalid URL format
41    InvalidUrl(AzString),
42    /// Connection failed
43    ConnectionFailed(AzString),
44    /// Request timed out
45    Timeout,
46    /// TLS/SSL error
47    TlsError(AzString),
48    /// HTTP error response (4xx, 5xx)
49    HttpStatus(HttpStatusError),
50    /// I/O error during request
51    IoError(AzString),
52    /// Response body too large
53    ResponseTooLarge(HttpResponseTooLargeError),
54    /// Other error
55    Other(AzString),
56}
57
58impl HttpError {
59    #[must_use] pub const fn invalid_url(url: AzString) -> Self {
60        Self::InvalidUrl(url)
61    }
62    
63    #[must_use] pub const fn connection_failed(msg: AzString) -> Self {
64        Self::ConnectionFailed(msg)
65    }
66    
67    #[must_use] pub const fn tls_error(msg: AzString) -> Self {
68        Self::TlsError(msg)
69    }
70    
71    #[must_use] pub const fn http_status(status_code: u16, message: AzString) -> Self {
72        Self::HttpStatus(HttpStatusError {
73            status_code,
74            message,
75        })
76    }
77    
78    #[must_use] pub const fn io_error(msg: AzString) -> Self {
79        Self::IoError(msg)
80    }
81    
82    #[must_use] pub const fn response_too_large(max_size: u64, actual_size: u64) -> Self {
83        Self::ResponseTooLarge(HttpResponseTooLargeError {
84            max_size,
85            actual_size,
86        })
87    }
88    
89    #[must_use] pub const fn other(msg: AzString) -> Self {
90        Self::Other(msg)
91    }
92}
93
94impl fmt::Display for HttpError {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::InvalidUrl(url) => write!(f, "Invalid URL: {}", url.as_str()),
98            Self::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg.as_str()),
99            Self::Timeout => write!(f, "Request timed out"),
100            Self::TlsError(msg) => write!(f, "TLS error: {}", msg.as_str()),
101            Self::HttpStatus(e) => write!(f, "HTTP {} - {}", e.status_code, e.message.as_str()),
102            Self::IoError(msg) => write!(f, "I/O error: {}", msg.as_str()),
103            Self::ResponseTooLarge(e) => {
104                write!(f, "Response too large: {} bytes (max: {})", e.actual_size, e.max_size)
105            }
106            Self::Other(msg) => write!(f, "HTTP error: {}", msg.as_str()),
107        }
108    }
109}
110
111#[cfg(feature = "std")]
112impl std::error::Error for HttpError {}
113
114/// Result type for HTTP operations
115pub type HttpResult<T> = Result<T, HttpError>;
116
117// FFI-safe Result types for HTTP operations
118use azul_css::{impl_result, impl_result_inner};
119
120// Forward declaration - actual impl_result! calls are after HttpResponse definition
121
122// ============================================================================
123// Request configuration (C-compatible)
124// ============================================================================
125
126/// HTTP header key-value pair
127#[derive(Debug, Clone, PartialEq, Eq)]
128#[repr(C)]
129pub struct HttpHeader {
130    /// Header name
131    pub name: AzString,
132    /// Header value
133    pub value: AzString,
134}
135
136impl HttpHeader {
137    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
138        Self {
139            name: AzString::from(name.into()),
140            value: AzString::from(value.into()),
141        }
142    }
143}
144
145impl_option!(HttpHeader, OptionHttpHeader, copy = false, [Debug, Clone, PartialEq, Eq]);
146impl_vec!(HttpHeader, HttpHeaderVec, HttpHeaderVecDestructor, HttpHeaderVecDestructorType, HttpHeaderVecSlice, OptionHttpHeader);
147impl_vec_clone!(HttpHeader, HttpHeaderVec, HttpHeaderVecDestructor);
148impl_vec_debug!(HttpHeader, HttpHeaderVec);
149impl_vec_partialeq!(HttpHeader, HttpHeaderVec);
150impl_vec_mut!(HttpHeader, HttpHeaderVec);
151
152/// HTTP request configuration (C-compatible)
153#[derive(Debug, Clone)]
154#[repr(C)]
155pub struct HttpRequestConfig {
156    /// Request timeout in seconds (default: 30)
157    pub timeout_secs: u64,
158    /// Maximum response size in bytes (default: 100MB, 0 = unlimited)
159    pub max_response_size: u64,
160    /// User-Agent header value
161    pub user_agent: AzString,
162    /// Additional headers
163    pub headers: HttpHeaderVec,
164    /// Disable TLS certificate verification (default: false).
165    /// WARNING: This makes HTTPS connections vulnerable to MITM attacks.
166    /// Use only for testing or when connecting to servers with self-signed
167    /// or cross-signed certificates not in the Mozilla root store.
168    pub disable_tls_cert_verification: bool,
169}
170
171impl Default for HttpRequestConfig {
172    fn default() -> Self {
173        Self {
174            timeout_secs: 30,
175            max_response_size: 100 * 1024 * 1024, // 100 MB
176            user_agent: AzString::from("azul-http/1.0".to_string()),
177            headers: HttpHeaderVec::from_const_slice(&[]),
178            disable_tls_cert_verification: false,
179        }
180    }
181}
182
183impl HttpRequestConfig {
184    /// Create a new config with default values
185    #[must_use] pub fn new() -> Self {
186        Self::default()
187    }
188    
189    /// Set timeout in seconds
190    #[must_use] pub const fn with_timeout(mut self, secs: u64) -> Self {
191        self.timeout_secs = secs;
192        self
193    }
194    
195    /// Set maximum response size (0 = unlimited)
196    #[must_use] pub const fn with_max_size(mut self, max_bytes: u64) -> Self {
197        self.max_response_size = max_bytes;
198        self
199    }
200    
201    /// Set User-Agent header
202    #[must_use]
203    pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
204        self.user_agent = AzString::from(ua.into());
205        self
206    }
207    
208    /// Add a header
209    #[must_use]
210    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
211        self.headers.push(HttpHeader::new(name, value));
212        self
213    }
214
215    /// Simple HTTP GET request with default configuration
216    ///
217    /// # Arguments
218    /// * `url` - The URL to request
219    ///
220    /// # Returns
221    /// * `ResultHttpResponseHttpError` - The response or an error
222    #[cfg(feature = "http")]
223    pub fn http_get_default(url: AzString) -> ResultHttpResponseHttpError {
224        let config = HttpRequestConfig::default();
225        http_get_with_config(url.as_str(), &config).into()
226    }
227
228    /// Stub: `http` feature disabled.
229    #[cfg(not(feature = "http"))]
230    #[must_use] pub fn http_get_default(_url: AzString) -> ResultHttpResponseHttpError {
231        ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
232    }
233
234    /// HTTP GET request using this configuration
235    /// 
236    /// # Arguments
237    /// * `url` - The URL to request
238    /// 
239    /// # Returns
240    /// * `ResultHttpResponseHttpError` - The response or an error
241    #[cfg(feature = "http")]
242    pub fn http_get(&self, url: AzString) -> ResultHttpResponseHttpError {
243        http_get_with_config(url.as_str(), self).into()
244    }
245
246    /// Stub: `http` feature disabled.
247    #[cfg(not(feature = "http"))]
248    #[must_use] pub fn http_get(&self, _url: AzString) -> ResultHttpResponseHttpError {
249        ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
250    }
251
252    /// Download URL to bytes with default configuration
253    /// 
254    /// # Arguments
255    /// * `url` - The URL to download
256    /// 
257    /// # Returns
258    /// * `ResultU8VecHttpError` - The response body or an error
259    #[cfg(feature = "http")]
260    pub fn download_bytes_default(url: AzString) -> ResultU8VecHttpError {
261        download_bytes(url.as_str()).into()
262    }
263
264    /// Stub: `http` feature disabled.
265    #[cfg(not(feature = "http"))]
266    #[must_use] pub fn download_bytes_default(_url: AzString) -> ResultU8VecHttpError {
267        ResultU8VecHttpError::Err(HttpError::other("http feature not enabled".into()))
268    }
269
270    /// Download URL to bytes using this configuration
271    /// 
272    /// # Arguments
273    /// * `url` - The URL to download
274    /// 
275    /// # Returns
276    /// * `ResultU8VecHttpError` - The response body or an error
277    #[cfg(feature = "http")]
278    pub fn download_bytes(&self, url: AzString) -> ResultU8VecHttpError {
279        download_bytes_with_config(url.as_str(), self).into()
280    }
281
282    /// Stub: `http` feature disabled.
283    #[cfg(not(feature = "http"))]
284    #[must_use] pub fn download_bytes(&self, _url: AzString) -> ResultU8VecHttpError {
285        ResultU8VecHttpError::Err(HttpError::other("http feature not enabled".into()))
286    }
287
288    /// Check if a URL is reachable (HEAD request)
289    /// 
290    /// # Arguments
291    /// * `url` - The URL to check
292    /// 
293    /// # Returns
294    /// * `bool` - True if reachable (2xx status)
295    #[cfg(feature = "http")]
296    pub fn is_url_reachable(url: AzString) -> bool {
297        is_url_reachable(url.as_str())
298    }
299
300    /// Stub: `http` feature disabled.
301    #[cfg(not(feature = "http"))]
302    #[must_use] pub fn is_url_reachable(_url: AzString) -> bool {
303        false
304    }
305}
306
307// ============================================================================
308// Response (C-compatible)
309// ============================================================================
310
311/// HTTP response with status code, headers, and body
312#[derive(Debug, Clone, PartialEq)]
313#[repr(C)]
314pub struct HttpResponse {
315    /// HTTP status code (200, 404, etc.)
316    pub status_code: u16,
317    /// Response body as bytes
318    pub body: U8Vec,
319    /// Content-Type header value
320    pub content_type: AzString,
321    /// Content-Length header value (0 if unknown)
322    pub content_length: u64,
323    /// Response headers
324    pub headers: HttpHeaderVec,
325}
326
327impl HttpResponse {
328    /// Check if the response was successful (2xx status)
329    #[must_use] pub const fn is_success(&self) -> bool {
330        self.status_code >= 200 && self.status_code < 300
331    }
332    
333    /// Check if the response is a redirect (3xx status)
334    #[must_use] pub const fn is_redirect(&self) -> bool {
335        self.status_code >= 300 && self.status_code < 400
336    }
337    
338    /// Check if the response is a client error (4xx status)
339    #[must_use] pub const fn is_client_error(&self) -> bool {
340        self.status_code >= 400 && self.status_code < 500
341    }
342    
343    /// Check if the response is a server error (5xx status)
344    #[must_use] pub const fn is_server_error(&self) -> bool {
345        self.status_code >= 500 && self.status_code < 600
346    }
347    
348    /// Try to convert the body to a UTF-8 string
349    #[must_use] pub fn body_as_string(&self) -> Option<AzString> {
350        core::str::from_utf8(self.body.as_slice())
351            .ok()
352            .map(|s| AzString::from(s.to_string()))
353    }
354}
355
356// FFI-safe Result types for HTTP operations (must be after HttpResponse definition)
357impl_result!(
358    HttpResponse,
359    HttpError,
360    ResultHttpResponseHttpError,
361    copy = false,
362    clone = false,
363    [Debug, Clone, PartialEq]
364);
365
366impl_result!(
367    U8Vec,
368    HttpError,
369    ResultU8VecHttpError,
370    copy = false,
371    clone = false,
372    [Debug, Clone, PartialEq, Eq]
373);
374
375/// Simple HTTP GET request
376///
377/// # Arguments
378/// * `url` - The URL to request
379///
380/// # Returns
381/// * `HttpResult<HttpResponse>` - The response or an error
382#[cfg(feature = "http")]
383pub fn http_get(url: &str) -> HttpResult<HttpResponse> {
384    http_get_with_config(url, &HttpRequestConfig::default())
385}
386
387/// Stub: `http` feature disabled.
388#[cfg(not(feature = "http"))]
389/// # Errors
390///
391/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
392pub fn http_get(_url: &str) -> HttpResult<HttpResponse> {
393    Err(HttpError::other("http feature not enabled".into()))
394}
395
396/// HTTP GET request with custom configuration
397/// 
398/// # Arguments
399/// * `url` - The URL to request
400/// * `config` - Request configuration
401/// 
402/// # Returns
403/// * `HttpResult<HttpResponse>` - The response or an error
404#[cfg(feature = "http")]
405fn make_agent(timeout_secs: u64, disable_tls_cert_verification: bool) -> ureq::Agent {
406    use std::time::Duration;
407
408    let mut tls_builder = ureq::tls::TlsConfig::builder()
409        .provider(ureq::tls::TlsProvider::Rustls)
410        .unversioned_rustls_crypto_provider(
411            std::sync::Arc::new(rustls_rustcrypto::provider())
412        );
413
414    if disable_tls_cert_verification {
415        tls_builder = tls_builder.disable_verification(true);
416    } else {
417        tls_builder = tls_builder.root_certs(ureq::tls::RootCerts::WebPki);
418    }
419
420    let tls_config = tls_builder.build();
421
422    ureq::Agent::config_builder()
423        .tls_config(tls_config)
424        .timeout_global(Some(Duration::from_secs(timeout_secs)))
425        .http_status_as_error(false)
426        .build()
427        .new_agent()
428}
429
430#[cfg(feature = "http")]
431pub fn http_get_with_config(url: &str, config: &HttpRequestConfig) -> HttpResult<HttpResponse> {
432    use std::io::Read;
433
434    let agent = make_agent(config.timeout_secs, config.disable_tls_cert_verification);
435
436    // Build the request
437    let mut request = agent.get(url);
438
439    // Add user agent
440    if !config.user_agent.as_str().is_empty() {
441        request = request.header("User-Agent", config.user_agent.as_str());
442    }
443
444    // Add custom headers
445    for header in config.headers.as_slice() {
446        request = request.header(header.name.as_str(), header.value.as_str());
447    }
448
449    // Execute request — map transport errors to specific HttpError variants
450    let response = request.call().map_err(|e| {
451        match &e {
452            ureq::Error::Timeout(_) => HttpError::Timeout,
453            ureq::Error::HostNotFound => HttpError::connection_failed(
454                format!("DNS resolution failed for {}", url).into(),
455            ),
456            ureq::Error::ConnectionFailed => HttpError::connection_failed(
457                format!("Connection failed: {}", url).into(),
458            ),
459            ureq::Error::Io(io_err) => HttpError::io_error(
460                format!("{}", io_err).into(),
461            ),
462            ureq::Error::BadUri(msg) => HttpError::invalid_url(
463                format!("{}: {}", url, msg).into(),
464            ),
465            ureq::Error::Tls(msg) => HttpError::tls_error(
466                format!("TLS error: {}", msg).into(),
467            ),
468            // Catch-all for feature-gated variants (Rustls, Pem, etc.)
469            _ => {
470                let msg = e.to_string();
471                if msg.starts_with("rustls:") || msg.contains("TLS") || msg.contains("certificate") {
472                    HttpError::tls_error(msg.into())
473                } else {
474                    HttpError::other(msg.into())
475                }
476            }
477        }
478    })?;
479
480    let status_code = response.status().as_u16();
481    let content_type = AzString::from(
482        response.headers().get("Content-Type")
483            .and_then(|v| v.to_str().ok())
484            .unwrap_or("application/octet-stream")
485            .to_string()
486    );
487    let content_length = response.headers().get("Content-Length")
488        .and_then(|v| v.to_str().ok())
489        .and_then(|s| s.parse::<u64>().ok())
490        .unwrap_or(0);
491
492    // Collect response headers
493    let mut headers = Vec::new();
494    for (name, value) in response.headers().iter() {
495        if let Ok(v) = value.to_str() {
496            headers.push(HttpHeader::new(name.to_string(), v.to_string()));
497        }
498    }
499
500    // Check response size limit
501    if config.max_response_size > 0 && content_length > config.max_response_size {
502        return Err(HttpError::response_too_large(
503            config.max_response_size,
504            content_length,
505        ));
506    }
507
508    // Read body with size limit
509    let mut body = Vec::new();
510    let limit = if config.max_response_size > 0 {
511        config.max_response_size as usize
512    } else {
513        usize::MAX
514    };
515    let mut body_reader = response.into_body();
516    let mut reader = body_reader.as_reader().take(limit as u64);
517    reader.read_to_end(&mut body).map_err(|e| HttpError::io_error(e.to_string().into()))?;
518
519    Ok(HttpResponse {
520        status_code,
521        body: U8Vec::from(body),
522        content_type,
523        content_length,
524        headers: HttpHeaderVec::from_vec(headers),
525    })
526}
527
528/// Stub: `http` feature disabled.
529#[cfg(not(feature = "http"))]
530/// # Errors
531///
532/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
533pub fn http_get_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<HttpResponse> {
534    Err(HttpError::other("http feature not enabled".into()))
535}
536
537/// Download a URL to bytes (convenience wrapper with default config)
538/// 
539/// # Arguments
540/// * `url` - The URL to download
541/// 
542/// # Returns
543/// * `HttpResult<U8Vec>` - The response body or an error
544#[cfg(feature = "http")]
545pub fn download_bytes(url: &str) -> HttpResult<U8Vec> {
546    download_bytes_with_config(url, &HttpRequestConfig::default())
547}
548
549/// Stub: `http` feature disabled.
550#[cfg(not(feature = "http"))]
551/// # Errors
552///
553/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
554pub fn download_bytes(_url: &str) -> HttpResult<U8Vec> {
555    Err(HttpError::other("http feature not enabled".into()))
556}
557
558/// Download a URL to bytes with custom configuration
559/// 
560/// # Arguments
561/// * `url` - The URL to download
562/// * `config` - Request configuration (timeout, max size, etc.)
563/// 
564/// # Returns
565/// * `HttpResult<U8Vec>` - The response body or an error
566#[cfg(feature = "http")]
567pub fn download_bytes_with_config(url: &str, config: &HttpRequestConfig) -> HttpResult<U8Vec> {
568    let response = http_get_with_config(url, config)?;
569    
570    // Check for successful status
571    if response.status_code >= 400 {
572        return Err(HttpError::http_status(
573            response.status_code,
574            format!("HTTP error {}", response.status_code).into(),
575        ));
576    }
577    
578    Ok(response.body)
579}
580
581/// Stub: `http` feature disabled.
582#[cfg(not(feature = "http"))]
583/// # Errors
584///
585/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
586pub fn download_bytes_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<U8Vec> {
587    Err(HttpError::other("http feature not enabled".into()))
588}
589
590/// Check if a URL is reachable (HEAD request)
591/// 
592/// # Arguments
593/// * `url` - The URL to check
594/// 
595/// # Returns
596/// * `bool` - True if reachable (2xx status)
597#[cfg(feature = "http")]
598pub fn is_url_reachable(url: &str) -> bool {
599    const REACHABILITY_TIMEOUT_SECS: u64 = 10;
600    let agent = make_agent(REACHABILITY_TIMEOUT_SECS, false);
601    match agent.head(url).call() {
602        Ok(resp) => {
603            let code = resp.status().as_u16();
604            code >= 200 && code < 300
605        }
606        Err(_) => false,
607    }
608}
609
610/// Stub: `http` feature disabled.
611#[cfg(not(feature = "http"))]
612#[must_use] pub const fn is_url_reachable(_url: &str) -> bool {
613    false
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    
620    #[test]
621    fn test_http_request_config_default() {
622        let config = HttpRequestConfig::default();
623        assert_eq!(config.timeout_secs, 30);
624        assert_eq!(config.max_response_size, 100 * 1024 * 1024);
625        assert!(!config.user_agent.as_str().is_empty());
626    }
627    
628    #[test]
629    fn test_http_response_status_checks() {
630        let response = HttpResponse {
631            status_code: 200,
632            body: U8Vec::from(Vec::new()),
633            content_type: AzString::from(String::new()),
634            content_length: 0,
635            headers: HttpHeaderVec::from_const_slice(&[]),
636        };
637        assert!(response.is_success());
638        assert!(!response.is_redirect());
639        assert!(!response.is_client_error());
640        assert!(!response.is_server_error());
641    }
642    
643    #[test]
644    fn test_http_error_constructors() {
645        let err = HttpError::http_status(404, "Not Found".into());
646        assert!(err.to_string().contains("404"));
647        
648        let err2 = HttpError::response_too_large(100, 200);
649        assert!(err2.to_string().contains("200"));
650    }
651}
652
653#[cfg(test)]
654mod autotest_generated {
655    use super::*;
656
657    // =========================================================================
658    // Shared fixtures
659    //
660    // Everything below is offline: the `http`-gated tests only touch URIs that
661    // fail during URI parsing (no DNS lookup, no socket) or construct a ureq
662    // agent without ever calling it.
663    // =========================================================================
664
665    /// 256 KiB of ASCII — used to check the constructors don't choke on big payloads.
666    fn huge_ascii() -> String {
667        "A".repeat(256 * 1024)
668    }
669
670    /// A string designed to break naive formatting / escaping.
671    const NASTY: &str = "\u{0}\r\n\t\"{}{{}}%s%n\u{7f}héllo·🦀·\u{202e}\u{feff}";
672
673    fn response_with_status(status_code: u16) -> HttpResponse {
674        HttpResponse {
675            status_code,
676            body: U8Vec::from(Vec::new()),
677            content_type: AzString::from("application/octet-stream"),
678            content_length: 0,
679            headers: HttpHeaderVec::from_const_slice(&[]),
680        }
681    }
682
683    fn response_with_body(body: Vec<u8>) -> HttpResponse {
684        HttpResponse {
685            status_code: 200,
686            body: U8Vec::from(body),
687            content_type: AzString::from("text/plain"),
688            content_length: 0,
689            headers: HttpHeaderVec::from_const_slice(&[]),
690        }
691    }
692
693    // =========================================================================
694    // HttpError constructors (`other` category) — extreme AzString payloads
695    // =========================================================================
696
697    #[test]
698    fn http_error_string_constructors_store_payload_verbatim() {
699        for payload in ["", "http://example.com", NASTY, huge_ascii().as_str()] {
700            let s = AzString::from(payload);
701
702            assert_eq!(
703                HttpError::invalid_url(s.clone()),
704                HttpError::InvalidUrl(s.clone())
705            );
706            assert_eq!(
707                HttpError::connection_failed(s.clone()),
708                HttpError::ConnectionFailed(s.clone())
709            );
710            assert_eq!(
711                HttpError::tls_error(s.clone()),
712                HttpError::TlsError(s.clone())
713            );
714            assert_eq!(
715                HttpError::io_error(s.clone()),
716                HttpError::IoError(s.clone())
717            );
718            assert_eq!(HttpError::other(s.clone()), HttpError::Other(s.clone()));
719
720            // The payload survives the round-trip through the enum untouched:
721            // no truncation at NUL, no escaping, no normalization.
722            match HttpError::invalid_url(s.clone()) {
723                HttpError::InvalidUrl(inner) => assert_eq!(inner.as_str(), payload),
724                other => panic!("wrong variant: {other:?}"),
725            }
726        }
727    }
728
729    #[test]
730    fn http_error_variants_are_not_conflated() {
731        let s = AzString::from("x");
732        assert_ne!(HttpError::invalid_url(s.clone()), HttpError::other(s.clone()));
733        assert_ne!(HttpError::tls_error(s.clone()), HttpError::io_error(s.clone()));
734        assert_ne!(HttpError::connection_failed(s.clone()), HttpError::Timeout);
735    }
736
737    // =========================================================================
738    // HttpError::http_status / response_too_large (`numeric` category)
739    // =========================================================================
740
741    #[test]
742    fn http_status_accepts_full_u16_range_without_clamping() {
743        // 0 and u16::MAX are not valid HTTP status codes, but the constructor is
744        // a plain data carrier: it must store them as-is rather than clamp/panic.
745        for code in [0_u16, 1, 99, 100, 200, 299, 400, 599, 600, 999, u16::MAX] {
746            let err = HttpError::http_status(code, AzString::from("msg"));
747            match err {
748                HttpError::HttpStatus(ref e) => {
749                    assert_eq!(e.status_code, code);
750                    assert_eq!(e.message.as_str(), "msg");
751                }
752                ref other => panic!("wrong variant: {other:?}"),
753            }
754            // Display must render the raw number, never a saturated stand-in.
755            assert!(err.to_string().contains(&code.to_string()));
756        }
757    }
758
759    #[test]
760    fn http_status_with_empty_and_huge_message() {
761        let empty = HttpError::http_status(u16::MAX, AzString::from(""));
762        assert_eq!(empty.to_string(), "HTTP 65535 - ");
763
764        let big = huge_ascii();
765        let huge = HttpError::http_status(0, AzString::from(big.as_str()));
766        assert_eq!(huge.to_string().len(), "HTTP 0 - ".len() + big.len());
767    }
768
769    #[test]
770    fn response_too_large_stores_both_sizes_at_u64_limits() {
771        // Includes the nonsensical actual < max ordering: the constructor performs
772        // no validation and no arithmetic, so nothing can overflow here.
773        for (max, actual) in [
774            (0_u64, 0_u64),
775            (0, u64::MAX),
776            (u64::MAX, 0),
777            (u64::MAX, u64::MAX),
778            (1, 1),
779            (100, 200),
780            (u64::MAX, u64::MAX - 1),
781        ] {
782            let err = HttpError::response_too_large(max, actual);
783            match err {
784                HttpError::ResponseTooLarge(ref e) => {
785                    assert_eq!(e.max_size, max);
786                    assert_eq!(e.actual_size, actual);
787                }
788                ref other => panic!("wrong variant: {other:?}"),
789            }
790            let msg = err.to_string();
791            assert!(msg.contains(&actual.to_string()));
792            assert!(msg.contains(&max.to_string()));
793        }
794    }
795
796    // =========================================================================
797    // Display impl (`serializer` category)
798    // =========================================================================
799
800    #[test]
801    fn display_is_non_empty_for_every_variant() {
802        let variants = [
803            HttpError::invalid_url(AzString::from("u")),
804            HttpError::connection_failed(AzString::from("c")),
805            HttpError::Timeout,
806            HttpError::tls_error(AzString::from("t")),
807            HttpError::http_status(500, AzString::from("s")),
808            HttpError::io_error(AzString::from("i")),
809            HttpError::response_too_large(1, 2),
810            HttpError::other(AzString::from("o")),
811        ];
812        for v in &variants {
813            let s = v.to_string();
814            assert!(!s.is_empty(), "empty Display for {v:?}");
815        }
816        assert_eq!(HttpError::Timeout.to_string(), "Request timed out");
817    }
818
819    #[test]
820    fn display_does_not_interpret_the_payload_as_a_format_string() {
821        // A payload full of `{}` / `%s` must be echoed literally — a Display impl
822        // that re-formatted its own output would either panic or eat the braces.
823        let err = HttpError::other(AzString::from("{} {0} {{}} %s %n"));
824        assert_eq!(err.to_string(), "HTTP error: {} {0} {{}} %s %n");
825    }
826
827    #[test]
828    fn display_preserves_nul_newlines_and_unicode() {
829        let err = HttpError::invalid_url(AzString::from(NASTY));
830        let s = err.to_string();
831        assert!(s.starts_with("Invalid URL: "));
832        assert!(s.ends_with(NASTY));
833        assert!(s.contains('\u{0}'));
834        assert!(s.contains('🦀'));
835    }
836
837    #[test]
838    fn display_of_edge_numeric_values_does_not_panic() {
839        assert_eq!(
840            HttpError::http_status(u16::MAX, AzString::from("x")).to_string(),
841            "HTTP 65535 - x"
842        );
843        assert_eq!(
844            HttpError::response_too_large(u64::MAX, u64::MAX).to_string(),
845            format!(
846                "Response too large: {} bytes (max: {})",
847                u64::MAX,
848                u64::MAX
849            )
850        );
851        assert_eq!(
852            HttpError::response_too_large(0, 0).to_string(),
853            "Response too large: 0 bytes (max: 0)"
854        );
855    }
856
857    // =========================================================================
858    // HttpHeader::new (`constructor` category)
859    // =========================================================================
860
861    #[test]
862    fn http_header_new_keeps_fields_exactly_as_given() {
863        for (name, value) in [
864            ("", ""),
865            ("Content-Type", "text/html; charset=utf-8"),
866            (NASTY, NASTY),
867            (huge_ascii().as_str(), ""),
868            ("", huge_ascii().as_str()),
869        ] {
870            let h = HttpHeader::new(name, value);
871            assert_eq!(h.name.as_str(), name);
872            assert_eq!(h.value.as_str(), value);
873        }
874    }
875
876    #[test]
877    fn http_header_new_does_not_sanitize_crlf() {
878        // Documented behaviour, not an endorsement: HttpHeader is a dumb pair, so a
879        // CRLF-bearing name is stored verbatim. Rejecting it is the transport's job
880        // (ureq validates at request time) — assert the value is at least not
881        // silently truncated at the newline, which would hide the injection attempt.
882        let h = HttpHeader::new("X-Evil\r\nInjected: 1", "v\r\nSet-Cookie: pwned=1");
883        assert_eq!(h.name.as_str(), "X-Evil\r\nInjected: 1");
884        assert_eq!(h.value.as_str(), "v\r\nSet-Cookie: pwned=1");
885    }
886
887    #[test]
888    fn http_header_new_accepts_string_and_str() {
889        let from_str = HttpHeader::new("a", "b");
890        let from_string = HttpHeader::new(String::from("a"), String::from("b"));
891        assert_eq!(from_str, from_string);
892    }
893
894    // =========================================================================
895    // HttpRequestConfig builders (`constructor` category)
896    // =========================================================================
897
898    #[test]
899    fn config_new_matches_default_and_documented_values() {
900        let a = HttpRequestConfig::new();
901        let b = HttpRequestConfig::default();
902        assert_eq!(a.timeout_secs, b.timeout_secs);
903        assert_eq!(a.max_response_size, b.max_response_size);
904        assert_eq!(a.user_agent.as_str(), b.user_agent.as_str());
905        assert_eq!(a.headers.len(), b.headers.len());
906        assert_eq!(
907            a.disable_tls_cert_verification,
908            b.disable_tls_cert_verification
909        );
910
911        assert_eq!(a.timeout_secs, 30);
912        assert_eq!(a.max_response_size, 100 * 1024 * 1024);
913        assert!(a.headers.is_empty());
914        // Secure by default: certificate verification must be ON unless opted out.
915        assert!(!a.disable_tls_cert_verification);
916    }
917
918    #[test]
919    fn with_timeout_stores_extremes_verbatim() {
920        for secs in [0_u64, 1, 30, u64::MAX / 2, u64::MAX - 1, u64::MAX] {
921            let cfg = HttpRequestConfig::new().with_timeout(secs);
922            assert_eq!(cfg.timeout_secs, secs);
923            // Nothing else may be disturbed by the setter.
924            assert_eq!(cfg.max_response_size, 100 * 1024 * 1024);
925            assert!(cfg.headers.is_empty());
926        }
927    }
928
929    #[test]
930    fn with_max_size_stores_extremes_verbatim() {
931        for max in [0_u64, 1, u64::MAX] {
932            let cfg = HttpRequestConfig::new().with_max_size(max);
933            assert_eq!(cfg.max_response_size, max);
934            assert_eq!(cfg.timeout_secs, 30);
935        }
936        // 0 is the documented "unlimited" sentinel, not a "reject everything" limit.
937        assert_eq!(HttpRequestConfig::new().with_max_size(0).max_response_size, 0);
938    }
939
940    #[test]
941    fn builder_setters_are_last_write_wins_and_independent() {
942        let cfg = HttpRequestConfig::new()
943            .with_timeout(1)
944            .with_timeout(u64::MAX)
945            .with_max_size(5)
946            .with_max_size(0)
947            .with_user_agent("first")
948            .with_user_agent("second");
949
950        assert_eq!(cfg.timeout_secs, u64::MAX);
951        assert_eq!(cfg.max_response_size, 0);
952        assert_eq!(cfg.user_agent.as_str(), "second");
953    }
954
955    #[test]
956    fn with_user_agent_accepts_empty_and_extreme_values() {
957        let empty = HttpRequestConfig::new().with_user_agent("");
958        // Empty UA is meaningful: http_get_with_config skips the header entirely.
959        assert!(empty.user_agent.as_str().is_empty());
960
961        let unicode = HttpRequestConfig::new().with_user_agent(NASTY);
962        assert_eq!(unicode.user_agent.as_str(), NASTY);
963
964        let big = huge_ascii();
965        let huge = HttpRequestConfig::new().with_user_agent(big.clone());
966        assert_eq!(huge.user_agent.as_str().len(), big.len());
967    }
968
969    #[test]
970    fn with_header_appends_in_order_and_keeps_duplicates() {
971        let mut cfg = HttpRequestConfig::new();
972        assert!(cfg.headers.is_empty());
973
974        for i in 0..100_usize {
975            cfg = cfg.with_header(format!("H{i}"), format!("v{i}"));
976        }
977        // Duplicate names are kept, not deduplicated or overwritten.
978        cfg = cfg.with_header("H0", "second-value");
979
980        assert_eq!(cfg.headers.len(), 101);
981        let slice = cfg.headers.as_slice();
982        for (i, h) in slice.iter().take(100).enumerate() {
983            assert_eq!(h.name.as_str(), format!("H{i}"));
984            assert_eq!(h.value.as_str(), format!("v{i}"));
985        }
986        assert_eq!(slice[100].name.as_str(), "H0");
987        assert_eq!(slice[100].value.as_str(), "second-value");
988    }
989
990    #[test]
991    fn with_header_accepts_empty_name_and_value() {
992        let cfg = HttpRequestConfig::new().with_header("", "");
993        assert_eq!(cfg.headers.len(), 1);
994        assert!(cfg.headers.as_slice()[0].name.as_str().is_empty());
995        assert!(cfg.headers.as_slice()[0].value.as_str().is_empty());
996    }
997
998    #[test]
999    fn cloning_a_config_gives_an_independent_header_vec() {
1000        // The header vec is an FFI vec with a destructor field; a shallow clone that
1001        // aliased the original's buffer would show up here (and later double-free).
1002        let base = HttpRequestConfig::new().with_header("A", "1");
1003        let cloned = base.clone().with_header("B", "2");
1004
1005        assert_eq!(base.headers.len(), 1);
1006        assert_eq!(cloned.headers.len(), 2);
1007        assert_eq!(base.headers.as_slice()[0].name.as_str(), "A");
1008        assert_eq!(cloned.headers.as_slice()[0].name.as_str(), "A");
1009        assert_eq!(cloned.headers.as_slice()[1].name.as_str(), "B");
1010
1011        drop(cloned);
1012        // `base` must still be readable after the clone is dropped.
1013        assert_eq!(base.headers.as_slice()[0].value.as_str(), "1");
1014    }
1015
1016    // =========================================================================
1017    // HttpResponse predicates (`predicate` category)
1018    // =========================================================================
1019
1020    #[test]
1021    fn status_predicates_at_class_boundaries() {
1022        let cases: &[(u16, bool, bool, bool, bool)] = &[
1023            // status, success, redirect, client_err, server_err
1024            (0, false, false, false, false),
1025            (100, false, false, false, false),
1026            (199, false, false, false, false),
1027            (200, true, false, false, false),
1028            (204, true, false, false, false),
1029            (299, true, false, false, false),
1030            (300, false, true, false, false),
1031            (399, false, true, false, false),
1032            (400, false, false, true, false),
1033            (499, false, false, true, false),
1034            (500, false, false, false, true),
1035            (599, false, false, false, true),
1036            (600, false, false, false, false),
1037            (999, false, false, false, false),
1038            (u16::MAX, false, false, false, false),
1039        ];
1040
1041        for &(status, success, redirect, client, server) in cases {
1042            let r = response_with_status(status);
1043            assert_eq!(r.is_success(), success, "is_success({status})");
1044            assert_eq!(r.is_redirect(), redirect, "is_redirect({status})");
1045            assert_eq!(r.is_client_error(), client, "is_client_error({status})");
1046            assert_eq!(r.is_server_error(), server, "is_server_error({status})");
1047        }
1048    }
1049
1050    #[test]
1051    fn status_predicates_are_mutually_exclusive_over_the_whole_u16_range() {
1052        let mut r = response_with_status(0);
1053        for status in 0..=u16::MAX {
1054            r.status_code = status;
1055            let hits = u8::from(r.is_success())
1056                + u8::from(r.is_redirect())
1057                + u8::from(r.is_client_error())
1058                + u8::from(r.is_server_error());
1059            assert!(hits <= 1, "status {status} matched {hits} classes");
1060            // Exactly one class must match inside 200..=599, and none outside it.
1061            let expected = u8::from((200_u16..600_u16).contains(&status));
1062            assert_eq!(hits, expected, "status {status}");
1063        }
1064    }
1065
1066    #[test]
1067    fn status_predicates_ignore_body_and_headers() {
1068        let mut r = response_with_body(vec![0xFF; 1024]);
1069        r.status_code = 503;
1070        r.content_length = u64::MAX;
1071        r.headers = HttpHeaderVec::from_vec(vec![HttpHeader::new("X", "Y")]);
1072        assert!(r.is_server_error());
1073        assert!(!r.is_success());
1074    }
1075
1076    // =========================================================================
1077    // HttpResponse::body_as_string (`getter` category)
1078    // =========================================================================
1079
1080    #[test]
1081    fn body_as_string_on_empty_body_is_some_empty_string() {
1082        let r = response_with_body(Vec::new());
1083        let s = r.body_as_string().expect("empty body is valid UTF-8");
1084        assert_eq!(s.as_str(), "");
1085    }
1086
1087    #[test]
1088    fn body_as_string_round_trips_valid_utf8() {
1089        for text in ["hello", NASTY, "🦀🦀🦀", "a\u{0}b"] {
1090            let r = response_with_body(text.as_bytes().to_vec());
1091            let s = r.body_as_string().expect("valid UTF-8 must decode");
1092            assert_eq!(s.as_str(), text);
1093            assert_eq!(s.as_str().len(), text.len());
1094        }
1095    }
1096
1097    #[test]
1098    fn body_as_string_returns_none_for_invalid_utf8() {
1099        let invalid: &[&[u8]] = &[
1100            &[0xFF],                    // never valid
1101            &[0x80],                    // lone continuation byte
1102            &[0xC3],                    // truncated 2-byte sequence
1103            &[0xE2, 0x82],              // truncated 3-byte sequence
1104            &[0xED, 0xA0, 0x80],        // UTF-16 surrogate half (CESU-8)
1105            &[0xF4, 0x90, 0x80, 0x80],  // above U+10FFFF
1106            &[0xC0, 0x80],              // overlong NUL
1107            &[b'o', b'k', 0xFE, b'!'],  // valid prefix, invalid tail
1108        ];
1109        for bytes in invalid {
1110            let r = response_with_body(bytes.to_vec());
1111            assert!(
1112                r.body_as_string().is_none(),
1113                "expected None for {bytes:02X?}"
1114            );
1115        }
1116    }
1117
1118    #[test]
1119    fn body_as_string_is_pure_and_repeatable() {
1120        let r = response_with_body(b"payload".to_vec());
1121        let first = r.body_as_string();
1122        let second = r.body_as_string();
1123        assert_eq!(first, second);
1124        // The getter must not consume or mutate the body.
1125        assert_eq!(r.body.as_slice(), &b"payload"[..]);
1126    }
1127
1128    #[test]
1129    fn body_as_string_ignores_a_lying_content_length() {
1130        // content_length is untrusted server metadata and is not an invariant of
1131        // `body`; the decoder must go by the actual byte slice.
1132        let mut r = response_with_body(b"1234".to_vec());
1133        r.content_length = u64::MAX;
1134        assert_eq!(r.body_as_string().expect("valid").as_str(), "1234");
1135
1136        r.content_length = 0;
1137        assert_eq!(r.body_as_string().expect("valid").as_str(), "1234");
1138    }
1139
1140    #[test]
1141    fn body_as_string_handles_a_large_body() {
1142        let big = huge_ascii();
1143        let r = response_with_body(big.clone().into_bytes());
1144        let s = r.body_as_string().expect("ASCII is valid UTF-8");
1145        assert_eq!(s.as_str().len(), big.len());
1146    }
1147
1148    // =========================================================================
1149    // FFI result round-trips (encode == decode)
1150    // =========================================================================
1151
1152    #[test]
1153    fn result_http_response_round_trips_through_the_ffi_enum() {
1154        let ok: Result<HttpResponse, HttpError> = Ok(response_with_status(200));
1155        let ffi: ResultHttpResponseHttpError = ok.clone().into();
1156        assert!(ffi.is_ok());
1157        assert!(!ffi.is_err());
1158        assert_eq!(ffi.into_result(), ok);
1159
1160        let err: Result<HttpResponse, HttpError> =
1161            Err(HttpError::http_status(u16::MAX, AzString::from(NASTY)));
1162        let ffi: ResultHttpResponseHttpError = err.clone().into();
1163        assert!(ffi.is_err());
1164        assert!(!ffi.is_ok());
1165        assert_eq!(ffi.into_result(), err);
1166    }
1167
1168    #[test]
1169    fn result_u8vec_round_trips_through_the_ffi_enum() {
1170        let ok: Result<U8Vec, HttpError> = Ok(U8Vec::from(vec![0u8, 0xFF, 0x7F]));
1171        let ffi: ResultU8VecHttpError = ok.clone().into();
1172        assert!(ffi.is_ok());
1173        assert_eq!(ffi.into_result(), ok);
1174
1175        let err: Result<U8Vec, HttpError> = Err(HttpError::response_too_large(0, u64::MAX));
1176        let ffi: ResultU8VecHttpError = err.clone().into();
1177        assert!(ffi.is_err());
1178        assert_eq!(ffi.into_result(), err);
1179
1180        // An empty Ok payload must stay Ok — not collapse into Err.
1181        let empty: ResultU8VecHttpError = Ok(U8Vec::from(Vec::new())).into();
1182        assert!(empty.is_ok());
1183        assert_eq!(empty.as_result().map(|v| v.len()), Ok(0));
1184    }
1185
1186    #[test]
1187    fn ffi_result_as_result_agrees_with_is_ok() {
1188        let ffi: ResultHttpResponseHttpError = Ok(response_with_status(404)).into();
1189        assert_eq!(ffi.is_ok(), ffi.as_result().is_ok());
1190        assert_eq!(
1191            ffi.as_result().map(HttpResponse::is_client_error),
1192            Ok(true)
1193        );
1194    }
1195
1196    // =========================================================================
1197    // `http` feature DISABLED — the stubs must fail closed
1198    // =========================================================================
1199
1200    #[cfg(not(feature = "http"))]
1201    #[test]
1202    fn stub_free_functions_return_err_for_any_url() {
1203        for url in ["", "https://example.com", NASTY, huge_ascii().as_str()] {
1204            let cfg = HttpRequestConfig::new();
1205            assert!(matches!(http_get(url), Err(HttpError::Other(_))));
1206            assert!(matches!(
1207                http_get_with_config(url, &cfg),
1208                Err(HttpError::Other(_))
1209            ));
1210            assert!(matches!(download_bytes(url), Err(HttpError::Other(_))));
1211            assert!(matches!(
1212                download_bytes_with_config(url, &cfg),
1213                Err(HttpError::Other(_))
1214            ));
1215        }
1216    }
1217
1218    #[cfg(not(feature = "http"))]
1219    #[test]
1220    fn stub_is_url_reachable_is_always_false() {
1221        // Fails closed: a disabled HTTP stack must never claim a URL is reachable.
1222        for url in ["", "https://example.com", NASTY, huge_ascii().as_str()] {
1223            assert!(!is_url_reachable(url));
1224            assert!(!HttpRequestConfig::is_url_reachable(AzString::from(url)));
1225        }
1226    }
1227
1228    #[cfg(not(feature = "http"))]
1229    #[test]
1230    fn stub_config_methods_return_err_results() {
1231        let cfg = HttpRequestConfig::new().with_timeout(u64::MAX).with_max_size(0);
1232        for url in ["", "https://example.com", NASTY] {
1233            let u = AzString::from(url);
1234            assert!(HttpRequestConfig::http_get_default(u.clone()).is_err());
1235            assert!(cfg.http_get(u.clone()).is_err());
1236            assert!(HttpRequestConfig::download_bytes_default(u.clone()).is_err());
1237            assert!(cfg.download_bytes(u.clone()).is_err());
1238        }
1239    }
1240
1241    // =========================================================================
1242    // `http` feature ENABLED — offline-only checks
1243    // =========================================================================
1244
1245    #[cfg(feature = "http")]
1246    #[test]
1247    fn make_agent_builds_at_timeout_extremes() {
1248        // Duration::from_secs(u64::MAX) is representable, so agent construction must
1249        // not panic at either end of the range (the agent is never called here).
1250        for secs in [0_u64, 1, 30, u64::MAX] {
1251            for disable_tls in [false, true] {
1252                let _agent = make_agent(secs, disable_tls);
1253            }
1254        }
1255    }
1256
1257    #[cfg(feature = "http")]
1258    #[test]
1259    fn malformed_urls_are_rejected_without_touching_the_network() {
1260        // Each of these fails in ureq's URI parser: no DNS resolution, no socket.
1261        let cfg = HttpRequestConfig::new().with_timeout(1);
1262        for url in ["", "not a url", "://no-scheme", "ht tp://spaces"] {
1263            assert!(http_get(url).is_err(), "expected Err for {url:?}");
1264            assert!(
1265                http_get_with_config(url, &cfg).is_err(),
1266                "expected Err for {url:?}"
1267            );
1268            assert!(download_bytes(url).is_err(), "expected Err for {url:?}");
1269            assert!(!is_url_reachable(url), "expected false for {url:?}");
1270        }
1271    }
1272
1273    #[cfg(feature = "http")]
1274    #[test]
1275    fn malformed_urls_are_rejected_through_the_ffi_wrappers() {
1276        let cfg = HttpRequestConfig::new().with_timeout(1);
1277        for url in ["", "not a url"] {
1278            let u = AzString::from(url);
1279            assert!(HttpRequestConfig::http_get_default(u.clone()).is_err());
1280            assert!(cfg.http_get(u.clone()).is_err());
1281            assert!(HttpRequestConfig::download_bytes_default(u.clone()).is_err());
1282            assert!(cfg.download_bytes(u.clone()).is_err());
1283            assert!(!HttpRequestConfig::is_url_reachable(u.clone()));
1284        }
1285    }
1286}