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

            
5
use alloc::string::String;
6
use alloc::vec::Vec;
7
use alloc::format;
8
use core::fmt;
9

            
10
use 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)]
19
pub 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)]
29
pub 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)]
39
pub 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

            
58
impl HttpError {
59
11
    #[must_use] pub const fn invalid_url(url: AzString) -> Self {
60
11
        Self::InvalidUrl(url)
61
11
    }
62
    
63
6
    #[must_use] pub const fn connection_failed(msg: AzString) -> Self {
64
6
        Self::ConnectionFailed(msg)
65
6
    }
66
    
67
6
    #[must_use] pub const fn tls_error(msg: AzString) -> Self {
68
6
        Self::TlsError(msg)
69
6
    }
70
    
71
17
    #[must_use] pub const fn http_status(status_code: u16, message: AzString) -> Self {
72
17
        Self::HttpStatus(HttpStatusError {
73
17
            status_code,
74
17
            message,
75
17
        })
76
17
    }
77
    
78
6
    #[must_use] pub const fn io_error(msg: AzString) -> Self {
79
6
        Self::IoError(msg)
80
6
    }
81
    
82
12
    #[must_use] pub const fn response_too_large(max_size: u64, actual_size: u64) -> Self {
83
12
        Self::ResponseTooLarge(HttpResponseTooLargeError {
84
12
            max_size,
85
12
            actual_size,
86
12
        })
87
12
    }
88
    
89
35
    #[must_use] pub const fn other(msg: AzString) -> Self {
90
35
        Self::Other(msg)
91
35
    }
92
}
93

            
94
impl fmt::Display for HttpError {
95
36
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96
36
        match self {
97
2
            Self::InvalidUrl(url) => write!(f, "Invalid URL: {}", url.as_str()),
98
1
            Self::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg.as_str()),
99
2
            Self::Timeout => write!(f, "Request timed out"),
100
1
            Self::TlsError(msg) => write!(f, "TLS error: {}", msg.as_str()),
101
16
            Self::HttpStatus(e) => write!(f, "HTTP {} - {}", e.status_code, e.message.as_str()),
102
1
            Self::IoError(msg) => write!(f, "I/O error: {}", msg.as_str()),
103
11
            Self::ResponseTooLarge(e) => {
104
11
                write!(f, "Response too large: {} bytes (max: {})", e.actual_size, e.max_size)
105
            }
106
2
            Self::Other(msg) => write!(f, "HTTP error: {}", msg.as_str()),
107
        }
108
36
    }
109
}
110

            
111
#[cfg(feature = "std")]
112
impl std::error::Error for HttpError {}
113

            
114
/// Result type for HTTP operations
115
pub type HttpResult<T> = Result<T, HttpError>;
116

            
117
// FFI-safe Result types for HTTP operations
118
use 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)]
129
pub struct HttpHeader {
130
    /// Header name
131
    pub name: AzString,
132
    /// Header value
133
    pub value: AzString,
134
}
135

            
136
impl HttpHeader {
137
113
    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
138
113
        Self {
139
113
            name: AzString::from(name.into()),
140
113
            value: AzString::from(value.into()),
141
113
        }
142
113
    }
143
}
144

            
145
impl_option!(HttpHeader, OptionHttpHeader, copy = false, [Debug, Clone, PartialEq, Eq]);
146
impl_vec!(HttpHeader, HttpHeaderVec, HttpHeaderVecDestructor, HttpHeaderVecDestructorType, HttpHeaderVecSlice, OptionHttpHeader);
147
impl_vec_clone!(HttpHeader, HttpHeaderVec, HttpHeaderVecDestructor);
148
impl_vec_debug!(HttpHeader, HttpHeaderVec);
149
impl_vec_partialeq!(HttpHeader, HttpHeaderVec);
150
impl_vec_mut!(HttpHeader, HttpHeaderVec);
151

            
152
/// HTTP request configuration (C-compatible)
153
#[derive(Debug, Clone)]
154
#[repr(C)]
155
pub 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

            
171
impl Default for HttpRequestConfig {
172
25
    fn default() -> Self {
173
25
        Self {
174
25
            timeout_secs: 30,
175
25
            max_response_size: 100 * 1024 * 1024, // 100 MB
176
25
            user_agent: AzString::from("azul-http/1.0".to_string()),
177
25
            headers: HttpHeaderVec::from_const_slice(&[]),
178
25
            disable_tls_cert_verification: false,
179
25
        }
180
25
    }
181
}
182

            
183
impl HttpRequestConfig {
184
    /// Create a new config with default values
185
23
    #[must_use] pub fn new() -> Self {
186
23
        Self::default()
187
23
    }
188
    
189
    /// Set timeout in seconds
190
9
    #[must_use] pub const fn with_timeout(mut self, secs: u64) -> Self {
191
9
        self.timeout_secs = secs;
192
9
        self
193
9
    }
194
    
195
    /// Set maximum response size (0 = unlimited)
196
7
    #[must_use] pub const fn with_max_size(mut self, max_bytes: u64) -> Self {
197
7
        self.max_response_size = max_bytes;
198
7
        self
199
7
    }
200
    
201
    /// Set User-Agent header
202
    #[must_use]
203
5
    pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
204
5
        self.user_agent = AzString::from(ua.into());
205
5
        self
206
5
    }
207
    
208
    /// Add a header
209
    #[must_use]
210
104
    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
211
104
        self.headers.push(HttpHeader::new(name, value));
212
104
        self
213
104
    }
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(all(feature = "http", not(target_arch = "wasm32")))]
223
    #[must_use] pub fn http_get_default(url: AzString) -> ResultHttpResponseHttpError {
224
        let config = Self::default();
225
        http_get_with_config(url.as_str(), &config).into()
226
    }
227

            
228
    /// Stub: `http` feature disabled.
229
    #[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
230
3
    #[must_use] pub fn http_get_default(_url: AzString) -> ResultHttpResponseHttpError {
231
3
        ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
232
3
    }
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(all(feature = "http", not(target_arch = "wasm32")))]
242
    #[must_use] 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(any(not(feature = "http"), target_arch = "wasm32"))]
248
3
    #[must_use] pub fn http_get(&self, _url: AzString) -> ResultHttpResponseHttpError {
249
3
        ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
250
3
    }
251

            
252
    /// HTTP request with an arbitrary verb and an optional body, using this
253
    /// configuration. An EMPTY `body` sends no body (GET/HEAD semantics);
254
    /// `content_type` is only applied when a body is present.
255
    ///
256
    /// # Returns
257
    /// * `ResultHttpResponseHttpError` - The response or an error
258
    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]
259
    #[must_use] pub fn http_request(
260
        &self,
261
        method: HttpMethod,
262
        url: AzString,
263
        body: U8Vec,
264
        content_type: AzString,
265
    ) -> ResultHttpResponseHttpError {
266
        let body_ref = body.as_ref();
267
        let body_opt = if body_ref.is_empty() { None } else { Some(body_ref) };
268
        http_request_with_config(method, url.as_str(), body_opt, content_type.as_str(), self)
269
            .into()
270
    }
271

            
272
    /// Stub: `http` feature disabled.
273
    #[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
274
    #[must_use] pub fn http_request(
275
        &self,
276
        _method: HttpMethod,
277
        _url: AzString,
278
        _body: U8Vec,
279
        _content_type: AzString,
280
    ) -> ResultHttpResponseHttpError {
281
        ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
282
    }
283

            
284
    /// HTTP POST with a body, using this configuration.
285
    ///
286
    /// # Returns
287
    /// * `ResultHttpResponseHttpError` - The response or an error
288
    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]
289
    #[must_use] pub fn http_post(
290
        &self,
291
        url: AzString,
292
        body: U8Vec,
293
        content_type: AzString,
294
    ) -> ResultHttpResponseHttpError {
295
        http_post_with_config(url.as_str(), body.as_ref(), content_type.as_str(), self).into()
296
    }
297

            
298
    /// Stub: `http` feature disabled.
299
    #[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
300
    #[must_use] pub fn http_post(
301
        &self,
302
        _url: AzString,
303
        _body: U8Vec,
304
        _content_type: AzString,
305
    ) -> ResultHttpResponseHttpError {
306
        ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
307
    }
308

            
309
    /// Download URL to bytes with default configuration
310
    /// 
311
    /// # Arguments
312
    /// * `url` - The URL to download
313
    /// 
314
    /// # Returns
315
    /// * `ResultU8VecHttpError` - The response body or an error
316
    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]
317
    #[must_use] pub fn download_bytes_default(url: AzString) -> ResultU8VecHttpError {
318
        download_bytes(url.as_str()).into()
319
    }
320

            
321
    /// Stub: `http` feature disabled.
322
    #[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
323
3
    #[must_use] pub fn download_bytes_default(_url: AzString) -> ResultU8VecHttpError {
324
3
        ResultU8VecHttpError::Err(HttpError::other("http feature not enabled".into()))
325
3
    }
326

            
327
    /// Download URL to bytes using this configuration
328
    /// 
329
    /// # Arguments
330
    /// * `url` - The URL to download
331
    /// 
332
    /// # Returns
333
    /// * `ResultU8VecHttpError` - The response body or an error
334
    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]
335
    #[must_use] pub fn download_bytes(&self, url: AzString) -> ResultU8VecHttpError {
336
        download_bytes_with_config(url.as_str(), self).into()
337
    }
338

            
339
    /// Stub: `http` feature disabled.
340
    #[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
341
3
    #[must_use] pub fn download_bytes(&self, _url: AzString) -> ResultU8VecHttpError {
342
3
        ResultU8VecHttpError::Err(HttpError::other("http feature not enabled".into()))
343
3
    }
344

            
345
    /// Check if a URL is reachable (HEAD request)
346
    /// 
347
    /// # Arguments
348
    /// * `url` - The URL to check
349
    /// 
350
    /// # Returns
351
    /// * `bool` - True if reachable (2xx status)
352
    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]
353
    #[must_use] pub fn is_url_reachable(url: AzString) -> bool {
354
        is_url_reachable(url.as_str())
355
    }
356

            
357
    /// Stub: `http` feature disabled.
358
    ///
359
    /// The Result-returning siblings self-describe via
360
    /// `HttpError::other("http feature not enabled")`; a bare `false` is the
361
    /// one answer here that reads exactly like "server down", so say the
362
    /// truth once. (The `const fn` free-function twin below cannot log.)
363
    #[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
364
4
    #[must_use] pub fn is_url_reachable(_url: AzString) -> bool {
365
        static ANNOUNCE: std::sync::Once = std::sync::Once::new();
366
4
        ANNOUNCE.call_once(|| {
367
1
            eprintln!(
368
1
                "[azul][http] is_url_reachable called, but this build has no `http` \
369
1
                 feature — it ALWAYS returns false (this is not a network result). \
370
1
                 Rebuild azul-layout with the `http` feature"
371
            );
372
1
        });
373
4
        false
374
4
    }
375
}
376

            
377
// ============================================================================
378
// Response (C-compatible)
379
// ============================================================================
380

            
381
/// HTTP response with status code, headers, and body
382
#[derive(Debug, Clone, PartialEq)]
383
#[repr(C)]
384
pub struct HttpResponse {
385
    /// HTTP status code (200, 404, etc.)
386
    pub status_code: u16,
387
    /// Response body as bytes
388
    pub body: U8Vec,
389
    /// Content-Type header value
390
    pub content_type: AzString,
391
    /// Content-Length header value (0 if unknown)
392
    pub content_length: u64,
393
    /// Response headers
394
    pub headers: HttpHeaderVec,
395
}
396

            
397
impl HttpResponse {
398
    /// Check if the response was successful (2xx status)
399
65553
    #[must_use] pub const fn is_success(&self) -> bool {
400
65553
        self.status_code >= 200 && self.status_code < 300
401
65553
    }
402
    
403
    /// Check if the response is a redirect (3xx status)
404
65552
    #[must_use] pub const fn is_redirect(&self) -> bool {
405
65552
        self.status_code >= 300 && self.status_code < 400
406
65552
    }
407
    
408
    /// Check if the response is a client error (4xx status)
409
65553
    #[must_use] pub const fn is_client_error(&self) -> bool {
410
65553
        self.status_code >= 400 && self.status_code < 500
411
65553
    }
412
    
413
    /// Check if the response is a server error (5xx status)
414
65553
    #[must_use] pub const fn is_server_error(&self) -> bool {
415
65553
        self.status_code >= 500 && self.status_code < 600
416
65553
    }
417
    
418
    /// Try to convert the body to a UTF-8 string
419
18
    #[must_use] pub fn body_as_string(&self) -> Option<AzString> {
420
18
        core::str::from_utf8(self.body.as_slice())
421
18
            .ok()
422
18
            .map(|s| AzString::from(s.to_string()))
423
18
    }
424
}
425

            
426
// FFI-safe Result types for HTTP operations (must be after HttpResponse definition)
427
impl_result!(
428
    HttpResponse,
429
    HttpError,
430
    ResultHttpResponseHttpError,
431
    copy = false,
432
    clone = false,
433
    [Debug, Clone, PartialEq]
434
);
435

            
436
impl_result!(
437
    U8Vec,
438
    HttpError,
439
    ResultU8VecHttpError,
440
    copy = false,
441
    clone = false,
442
    [Debug, Clone, PartialEq, Eq]
443
);
444

            
445
/// Simple HTTP GET request
446
///
447
/// # Arguments
448
/// * `url` - The URL to request
449
///
450
/// # Returns
451
/// * `HttpResult<HttpResponse>` - The response or an error
452
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
453
pub fn http_get(url: &str) -> HttpResult<HttpResponse> {
454
    http_get_with_config(url, &HttpRequestConfig::default())
455
}
456

            
457
/// Stub: `http` feature disabled.
458
#[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
459
/// # Errors
460
///
461
/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
462
4
pub fn http_get(_url: &str) -> HttpResult<HttpResponse> {
463
4
    Err(HttpError::other("http feature not enabled".into()))
464
4
}
465

            
466
/// HTTP GET request with custom configuration
467
/// 
468
/// # Arguments
469
/// * `url` - The URL to request
470
/// * `config` - Request configuration
471
/// 
472
/// # Returns
473
/// * `HttpResult<HttpResponse>` - The response or an error
474
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
475
fn make_agent(timeout_secs: u64, disable_tls_cert_verification: bool) -> ureq::Agent {
476
    use std::time::Duration;
477

            
478
    let mut tls_builder = ureq::tls::TlsConfig::builder()
479
        .provider(ureq::tls::TlsProvider::Rustls)
480
        .unversioned_rustls_crypto_provider(
481
            std::sync::Arc::new(rustls_rustcrypto::provider())
482
        );
483

            
484
    if disable_tls_cert_verification {
485
        tls_builder = tls_builder.disable_verification(true);
486
    } else {
487
        tls_builder = tls_builder.root_certs(ureq::tls::RootCerts::WebPki);
488
    }
489

            
490
    let tls_config = tls_builder.build();
491

            
492
    ureq::Agent::config_builder()
493
        .tls_config(tls_config)
494
        .timeout_global(Some(Duration::from_secs(timeout_secs)))
495
        .http_status_as_error(false)
496
        .build()
497
        .new_agent()
498
}
499

            
500
/// HTTP verb for [`http_request_with_config`].
501
///
502
/// Rust-side only — this type deliberately has no C-ABI mirror in `api.json`;
503
/// the C bindings keep the pre-existing `HttpRequestConfig::http_get` /
504
/// `download_bytes` entry points.
505
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
506
#[repr(C)]
507
pub enum HttpMethod {
508
    Get,
509
    Head,
510
    Post,
511
    Put,
512
    Patch,
513
    Delete,
514
}
515

            
516
impl HttpMethod {
517
    /// The uppercase wire name of the verb.
518
    #[must_use]
519
    pub const fn as_str(self) -> &'static str {
520
        match self {
521
            Self::Get => "GET",
522
            Self::Head => "HEAD",
523
            Self::Post => "POST",
524
            Self::Put => "PUT",
525
            Self::Patch => "PATCH",
526
            Self::Delete => "DELETE",
527
        }
528
    }
529

            
530
    /// Whether this verb carries a request body.
531
    ///
532
    /// Mirrors ureq's request typestate split: `POST`/`PUT`/`PATCH` build a
533
    /// `WithBody` request terminated by `send()`, while `GET`/`HEAD`/`DELETE`
534
    /// build a `WithoutBody` one terminated by `call()`.
535
    #[must_use]
536
    pub const fn takes_body(self) -> bool {
537
        matches!(self, Self::Post | Self::Put | Self::Patch)
538
    }
539
}
540

            
541
impl fmt::Display for HttpMethod {
542
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
543
        f.write_str(self.as_str())
544
    }
545
}
546

            
547
/// Maps a ureq transport error onto the C-ABI-safe [`HttpError`].
548
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
549
fn map_ureq_error(url: &str, e: &ureq::Error) -> HttpError {
550
    match e {
551
        ureq::Error::Timeout(_) => HttpError::Timeout,
552
        ureq::Error::HostNotFound => {
553
            HttpError::connection_failed(format!("DNS resolution failed for {url}").into())
554
        }
555
        ureq::Error::ConnectionFailed => {
556
            HttpError::connection_failed(format!("Connection failed: {url}").into())
557
        }
558
        ureq::Error::Io(io_err) => HttpError::io_error(format!("{io_err}").into()),
559
        ureq::Error::BadUri(msg) => HttpError::invalid_url(format!("{url}: {msg}").into()),
560
        ureq::Error::Tls(msg) => HttpError::tls_error(format!("TLS error: {msg}").into()),
561
        // Catch-all for feature-gated variants (Rustls, Pem, etc.)
562
        _ => {
563
            let msg = e.to_string();
564
            if msg.starts_with("rustls:") || msg.contains("TLS") || msg.contains("certificate") {
565
                HttpError::tls_error(msg.into())
566
            } else {
567
                HttpError::other(msg.into())
568
            }
569
        }
570
    }
571
}
572

            
573
/// Turns a ureq response into the C-ABI [`HttpResponse`], enforcing
574
/// `config.max_response_size` both on the advertised `Content-Length` and on
575
/// the actual number of bytes read.
576
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
577
fn decode_response(
578
    response: ureq::http::Response<ureq::Body>,
579
    config: &HttpRequestConfig,
580
) -> HttpResult<HttpResponse> {
581
    use std::io::Read;
582

            
583
    let status_code = response.status().as_u16();
584
    let content_type = AzString::from(
585
        response.headers().get("Content-Type")
586
            .and_then(|v| v.to_str().ok())
587
            .unwrap_or("application/octet-stream")
588
            .to_string()
589
    );
590
    let content_length = response.headers().get("Content-Length")
591
        .and_then(|v| v.to_str().ok())
592
        .and_then(|s| s.parse::<u64>().ok())
593
        .unwrap_or(0);
594

            
595
    // Collect response headers
596
    let mut headers = Vec::new();
597
    for (name, value) in response.headers() {
598
        if let Ok(v) = value.to_str() {
599
            headers.push(HttpHeader::new(name.to_string(), v.to_string()));
600
        }
601
    }
602

            
603
    // Check response size limit
604
    if config.max_response_size > 0 && content_length > config.max_response_size {
605
        return Err(HttpError::response_too_large(
606
            config.max_response_size,
607
            content_length,
608
        ));
609
    }
610

            
611
    // Read body with size limit
612
    let mut body = Vec::new();
613
    let limit = if config.max_response_size > 0 {
614
        config.max_response_size as usize
615
    } else {
616
        usize::MAX
617
    };
618
    let mut body_reader = response.into_body();
619
    let mut reader = body_reader.as_reader().take(limit as u64);
620
    reader.read_to_end(&mut body).map_err(|e| HttpError::io_error(e.to_string().into()))?;
621

            
622
    Ok(HttpResponse {
623
        status_code,
624
        body: U8Vec::from(body),
625
        content_type,
626
        content_length,
627
        headers: HttpHeaderVec::from_vec(headers),
628
    })
629
}
630

            
631
/// Generic HTTP request with an optional body — the single code path every
632
/// verb-specific helper in this module funnels through.
633
///
634
/// `content_type` is applied only when a body is present; explicit entries in
635
/// `config.headers` are applied afterwards and therefore win. To gzip a
636
/// request body, compress it yourself and pass
637
/// `HttpRequestConfig::with_header("Content-Encoding", "gzip")`.
638
///
639
/// Note that 4xx/5xx are returned as an `Ok(HttpResponse)` with the status
640
/// code set (the agent is built with `http_status_as_error(false)`); only
641
/// transport failures produce an `Err`.
642
///
643
/// # Errors
644
///
645
/// Returns an `HttpError` on DNS/connect/TLS/IO failure, on timeout, if the
646
/// response exceeds `config.max_response_size`, or if the `http` feature is
647
/// disabled.
648
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
649
pub fn http_request_with_config(
650
    method: HttpMethod,
651
    url: &str,
652
    body: Option<&[u8]>,
653
    content_type: &str,
654
    config: &HttpRequestConfig,
655
) -> HttpResult<HttpResponse> {
656
    let agent = make_agent(config.timeout_secs, config.disable_tls_cert_verification);
657

            
658
    // ureq 3.x splits the request builder by typestate: `WithoutBody` for
659
    // GET/HEAD/DELETE (terminated by `.call()`) and `WithBody` for
660
    // POST/PUT/PATCH (terminated by `.send()`). The two are different types,
661
    // so the header application is written out per branch.
662
    let response = if method.takes_body() {
663
        let mut request = match method {
664
            HttpMethod::Put => agent.put(url),
665
            HttpMethod::Patch => agent.patch(url),
666
            // `takes_body()` admits only POST/PUT/PATCH here.
667
            _ => agent.post(url),
668
        };
669
        if !config.user_agent.as_str().is_empty() {
670
            request = request.header("User-Agent", config.user_agent.as_str());
671
        }
672
        if !content_type.is_empty() {
673
            request = request.header("Content-Type", content_type);
674
        }
675
        for header in config.headers.as_slice() {
676
            request = request.header(header.name.as_str(), header.value.as_str());
677
        }
678
        request
679
            .send(body.unwrap_or(&[]))
680
            .map_err(|e| map_ureq_error(url, &e))?
681
    } else {
682
        let mut request = match method {
683
            HttpMethod::Head => agent.head(url),
684
            HttpMethod::Delete => agent.delete(url),
685
            // `takes_body()` admits only GET/HEAD/DELETE here.
686
            _ => agent.get(url),
687
        };
688
        if !config.user_agent.as_str().is_empty() {
689
            request = request.header("User-Agent", config.user_agent.as_str());
690
        }
691
        for header in config.headers.as_slice() {
692
            request = request.header(header.name.as_str(), header.value.as_str());
693
        }
694
        request.call().map_err(|e| map_ureq_error(url, &e))?
695
    };
696

            
697
    decode_response(response, config)
698
}
699

            
700
/// Stub: `http` feature disabled.
701
///
702
/// # Errors
703
///
704
/// Always returns an `HttpError` — the networking feature is disabled.
705
#[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
706
pub fn http_request_with_config(
707
    _method: HttpMethod,
708
    _url: &str,
709
    _body: Option<&[u8]>,
710
    _content_type: &str,
711
    _config: &HttpRequestConfig,
712
) -> HttpResult<HttpResponse> {
713
    Err(HttpError::other("http feature not enabled".into()))
714
}
715

            
716
/// HTTP GET request with custom configuration.
717
///
718
/// # Errors
719
///
720
/// See [`http_request_with_config`].
721
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
722
pub fn http_get_with_config(url: &str, config: &HttpRequestConfig) -> HttpResult<HttpResponse> {
723
    http_request_with_config(HttpMethod::Get, url, None, "", config)
724
}
725

            
726
/// Stub: `http` feature disabled.
727
#[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
728
/// # Errors
729
///
730
/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
731
4
pub fn http_get_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<HttpResponse> {
732
4
    Err(HttpError::other("http feature not enabled".into()))
733
4
}
734

            
735
/// HTTP POST with the default configuration.
736
///
737
/// # Errors
738
///
739
/// See [`http_request_with_config`].
740
pub fn http_post(url: &str, body: &[u8], content_type: &str) -> HttpResult<HttpResponse> {
741
    http_post_with_config(url, body, content_type, &HttpRequestConfig::default())
742
}
743

            
744
/// HTTP POST with custom configuration.
745
///
746
/// This is the transport under the telemetry uploader (OTLP/HTTP JSON), crash
747
/// bundle upload and the update-manifest fetch.
748
///
749
/// # Errors
750
///
751
/// See [`http_request_with_config`].
752
pub fn http_post_with_config(
753
    url: &str,
754
    body: &[u8],
755
    content_type: &str,
756
    config: &HttpRequestConfig,
757
) -> HttpResult<HttpResponse> {
758
    http_request_with_config(HttpMethod::Post, url, Some(body), content_type, config)
759
}
760

            
761
/// HTTP PUT with custom configuration.
762
///
763
/// # Errors
764
///
765
/// See [`http_request_with_config`].
766
pub fn http_put_with_config(
767
    url: &str,
768
    body: &[u8],
769
    content_type: &str,
770
    config: &HttpRequestConfig,
771
) -> HttpResult<HttpResponse> {
772
    http_request_with_config(HttpMethod::Put, url, Some(body), content_type, config)
773
}
774

            
775
/// Download a URL to bytes (convenience wrapper with default config)
776
/// 
777
/// # Arguments
778
/// * `url` - The URL to download
779
/// 
780
/// # Returns
781
/// * `HttpResult<U8Vec>` - The response body or an error
782
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
783
pub fn download_bytes(url: &str) -> HttpResult<U8Vec> {
784
    download_bytes_with_config(url, &HttpRequestConfig::default())
785
}
786

            
787
/// Stub: `http` feature disabled.
788
#[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
789
/// # Errors
790
///
791
/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
792
4
pub fn download_bytes(_url: &str) -> HttpResult<U8Vec> {
793
4
    Err(HttpError::other("http feature not enabled".into()))
794
4
}
795

            
796
/// Download a URL to bytes with custom configuration
797
/// 
798
/// # Arguments
799
/// * `url` - The URL to download
800
/// * `config` - Request configuration (timeout, max size, etc.)
801
/// 
802
/// # Returns
803
/// * `HttpResult<U8Vec>` - The response body or an error
804
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
805
pub fn download_bytes_with_config(url: &str, config: &HttpRequestConfig) -> HttpResult<U8Vec> {
806
    let response = http_get_with_config(url, config)?;
807
    
808
    // Check for successful status
809
    if response.status_code >= 400 {
810
        return Err(HttpError::http_status(
811
            response.status_code,
812
            format!("HTTP error {}", response.status_code).into(),
813
        ));
814
    }
815
    
816
    Ok(response.body)
817
}
818

            
819
/// Stub: `http` feature disabled.
820
#[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
821
/// # Errors
822
///
823
/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
824
4
pub fn download_bytes_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<U8Vec> {
825
4
    Err(HttpError::other("http feature not enabled".into()))
826
4
}
827

            
828
/// Check if a URL is reachable (HEAD request)
829
/// 
830
/// # Arguments
831
/// * `url` - The URL to check
832
/// 
833
/// # Returns
834
/// * `bool` - True if reachable (2xx status)
835
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
836
#[must_use] pub fn is_url_reachable(url: &str) -> bool {
837
    const REACHABILITY_TIMEOUT_SECS: u64 = 10;
838
    let agent = make_agent(REACHABILITY_TIMEOUT_SECS, false);
839
    match agent.head(url).call() {
840
        Ok(resp) => {
841
            let code = resp.status().as_u16();
842
            (200..300).contains(&code)
843
        }
844
        Err(_) => false,
845
    }
846
}
847

            
848
/// Stub: `http` feature disabled.
849
#[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
850
4
#[must_use] pub const fn is_url_reachable(_url: &str) -> bool {
851
4
    false
852
4
}
853

            
854
#[cfg(test)]
855
mod tests {
856
    use super::*;
857
    
858
    #[test]
859
1
    fn test_http_request_config_default() {
860
1
        let config = HttpRequestConfig::default();
861
1
        assert_eq!(config.timeout_secs, 30);
862
1
        assert_eq!(config.max_response_size, 100 * 1024 * 1024);
863
1
        assert!(!config.user_agent.as_str().is_empty());
864
1
    }
865
    
866
    #[test]
867
1
    fn test_http_response_status_checks() {
868
1
        let response = HttpResponse {
869
1
            status_code: 200,
870
1
            body: U8Vec::from(Vec::new()),
871
1
            content_type: AzString::from(String::new()),
872
1
            content_length: 0,
873
1
            headers: HttpHeaderVec::from_const_slice(&[]),
874
1
        };
875
1
        assert!(response.is_success());
876
1
        assert!(!response.is_redirect());
877
1
        assert!(!response.is_client_error());
878
1
        assert!(!response.is_server_error());
879
1
    }
880
    
881
    #[test]
882
1
    fn test_http_error_constructors() {
883
1
        let err = HttpError::http_status(404, "Not Found".into());
884
1
        assert!(err.to_string().contains("404"));
885
        
886
1
        let err2 = HttpError::response_too_large(100, 200);
887
1
        assert!(err2.to_string().contains("200"));
888
1
    }
889
}
890

            
891
#[cfg(test)]
892
mod autotest_generated {
893
    use super::*;
894

            
895
    // =========================================================================
896
    // Shared fixtures
897
    //
898
    // Everything below is offline: the `http`-gated tests only touch URIs that
899
    // fail during URI parsing (no DNS lookup, no socket) or construct a ureq
900
    // agent without ever calling it.
901
    // =========================================================================
902

            
903
    /// 256 KiB of ASCII — used to check the constructors don't choke on big payloads.
904
    fn huge_ascii() -> String {
905
        "A".repeat(256 * 1024)
906
    }
907

            
908
    /// A string designed to break naive formatting / escaping.
909
    const NASTY: &str = "\u{0}\r\n\t\"{}{{}}%s%n\u{7f}héllo·🦀·\u{202e}\u{feff}";
910

            
911
    fn response_with_status(status_code: u16) -> HttpResponse {
912
        HttpResponse {
913
            status_code,
914
            body: U8Vec::from(Vec::new()),
915
            content_type: AzString::from("application/octet-stream"),
916
            content_length: 0,
917
            headers: HttpHeaderVec::from_const_slice(&[]),
918
        }
919
    }
920

            
921
    fn response_with_body(body: Vec<u8>) -> HttpResponse {
922
        HttpResponse {
923
            status_code: 200,
924
            body: U8Vec::from(body),
925
            content_type: AzString::from("text/plain"),
926
            content_length: 0,
927
            headers: HttpHeaderVec::from_const_slice(&[]),
928
        }
929
    }
930

            
931
    // =========================================================================
932
    // HttpError constructors (`other` category) — extreme AzString payloads
933
    // =========================================================================
934

            
935
    #[test]
936
    fn http_error_string_constructors_store_payload_verbatim() {
937
        for payload in ["", "http://example.com", NASTY, huge_ascii().as_str()] {
938
            let s = AzString::from(payload);
939

            
940
            assert_eq!(
941
                HttpError::invalid_url(s.clone()),
942
                HttpError::InvalidUrl(s.clone())
943
            );
944
            assert_eq!(
945
                HttpError::connection_failed(s.clone()),
946
                HttpError::ConnectionFailed(s.clone())
947
            );
948
            assert_eq!(
949
                HttpError::tls_error(s.clone()),
950
                HttpError::TlsError(s.clone())
951
            );
952
            assert_eq!(
953
                HttpError::io_error(s.clone()),
954
                HttpError::IoError(s.clone())
955
            );
956
            assert_eq!(HttpError::other(s.clone()), HttpError::Other(s.clone()));
957

            
958
            // The payload survives the round-trip through the enum untouched:
959
            // no truncation at NUL, no escaping, no normalization.
960
            match HttpError::invalid_url(s.clone()) {
961
                HttpError::InvalidUrl(inner) => assert_eq!(inner.as_str(), payload),
962
                other => panic!("wrong variant: {other:?}"),
963
            }
964
        }
965
    }
966

            
967
    #[test]
968
    fn http_error_variants_are_not_conflated() {
969
        let s = AzString::from("x");
970
        assert_ne!(HttpError::invalid_url(s.clone()), HttpError::other(s.clone()));
971
        assert_ne!(HttpError::tls_error(s.clone()), HttpError::io_error(s.clone()));
972
        assert_ne!(HttpError::connection_failed(s.clone()), HttpError::Timeout);
973
    }
974

            
975
    // =========================================================================
976
    // HttpError::http_status / response_too_large (`numeric` category)
977
    // =========================================================================
978

            
979
    #[test]
980
    fn http_status_accepts_full_u16_range_without_clamping() {
981
        // 0 and u16::MAX are not valid HTTP status codes, but the constructor is
982
        // a plain data carrier: it must store them as-is rather than clamp/panic.
983
        for code in [0_u16, 1, 99, 100, 200, 299, 400, 599, 600, 999, u16::MAX] {
984
            let err = HttpError::http_status(code, AzString::from("msg"));
985
            match err {
986
                HttpError::HttpStatus(ref e) => {
987
                    assert_eq!(e.status_code, code);
988
                    assert_eq!(e.message.as_str(), "msg");
989
                }
990
                ref other => panic!("wrong variant: {other:?}"),
991
            }
992
            // Display must render the raw number, never a saturated stand-in.
993
            assert!(err.to_string().contains(&code.to_string()));
994
        }
995
    }
996

            
997
    #[test]
998
    fn http_status_with_empty_and_huge_message() {
999
        let empty = HttpError::http_status(u16::MAX, AzString::from(""));
        assert_eq!(empty.to_string(), "HTTP 65535 - ");
        let big = huge_ascii();
        let huge = HttpError::http_status(0, AzString::from(big.as_str()));
        assert_eq!(huge.to_string().len(), "HTTP 0 - ".len() + big.len());
    }
    #[test]
    fn response_too_large_stores_both_sizes_at_u64_limits() {
        // Includes the nonsensical actual < max ordering: the constructor performs
        // no validation and no arithmetic, so nothing can overflow here.
        for (max, actual) in [
            (0_u64, 0_u64),
            (0, u64::MAX),
            (u64::MAX, 0),
            (u64::MAX, u64::MAX),
            (1, 1),
            (100, 200),
            (u64::MAX, u64::MAX - 1),
        ] {
            let err = HttpError::response_too_large(max, actual);
            match err {
                HttpError::ResponseTooLarge(ref e) => {
                    assert_eq!(e.max_size, max);
                    assert_eq!(e.actual_size, actual);
                }
                ref other => panic!("wrong variant: {other:?}"),
            }
            let msg = err.to_string();
            assert!(msg.contains(&actual.to_string()));
            assert!(msg.contains(&max.to_string()));
        }
    }
    // =========================================================================
    // Display impl (`serializer` category)
    // =========================================================================
    #[test]
    fn display_is_non_empty_for_every_variant() {
        let variants = [
            HttpError::invalid_url(AzString::from("u")),
            HttpError::connection_failed(AzString::from("c")),
            HttpError::Timeout,
            HttpError::tls_error(AzString::from("t")),
            HttpError::http_status(500, AzString::from("s")),
            HttpError::io_error(AzString::from("i")),
            HttpError::response_too_large(1, 2),
            HttpError::other(AzString::from("o")),
        ];
        for v in &variants {
            let s = v.to_string();
            assert!(!s.is_empty(), "empty Display for {v:?}");
        }
        assert_eq!(HttpError::Timeout.to_string(), "Request timed out");
    }
    #[test]
    fn display_does_not_interpret_the_payload_as_a_format_string() {
        // A payload full of `{}` / `%s` must be echoed literally — a Display impl
        // that re-formatted its own output would either panic or eat the braces.
        let err = HttpError::other(AzString::from("{} {0} {{}} %s %n"));
        assert_eq!(err.to_string(), "HTTP error: {} {0} {{}} %s %n");
    }
    #[test]
    fn display_preserves_nul_newlines_and_unicode() {
        let err = HttpError::invalid_url(AzString::from(NASTY));
        let s = err.to_string();
        assert!(s.starts_with("Invalid URL: "));
        assert!(s.ends_with(NASTY));
        assert!(s.contains('\u{0}'));
        assert!(s.contains('🦀'));
    }
    #[test]
    fn display_of_edge_numeric_values_does_not_panic() {
        assert_eq!(
            HttpError::http_status(u16::MAX, AzString::from("x")).to_string(),
            "HTTP 65535 - x"
        );
        assert_eq!(
            HttpError::response_too_large(u64::MAX, u64::MAX).to_string(),
            format!(
                "Response too large: {} bytes (max: {})",
                u64::MAX,
                u64::MAX
            )
        );
        assert_eq!(
            HttpError::response_too_large(0, 0).to_string(),
            "Response too large: 0 bytes (max: 0)"
        );
    }
    // =========================================================================
    // HttpHeader::new (`constructor` category)
    // =========================================================================
    #[test]
    fn http_header_new_keeps_fields_exactly_as_given() {
        for (name, value) in [
            ("", ""),
            ("Content-Type", "text/html; charset=utf-8"),
            (NASTY, NASTY),
            (huge_ascii().as_str(), ""),
            ("", huge_ascii().as_str()),
        ] {
            let h = HttpHeader::new(name, value);
            assert_eq!(h.name.as_str(), name);
            assert_eq!(h.value.as_str(), value);
        }
    }
    #[test]
    fn http_header_new_does_not_sanitize_crlf() {
        // Documented behaviour, not an endorsement: HttpHeader is a dumb pair, so a
        // CRLF-bearing name is stored verbatim. Rejecting it is the transport's job
        // (ureq validates at request time) — assert the value is at least not
        // silently truncated at the newline, which would hide the injection attempt.
        let h = HttpHeader::new("X-Evil\r\nInjected: 1", "v\r\nSet-Cookie: pwned=1");
        assert_eq!(h.name.as_str(), "X-Evil\r\nInjected: 1");
        assert_eq!(h.value.as_str(), "v\r\nSet-Cookie: pwned=1");
    }
    #[test]
    fn http_header_new_accepts_string_and_str() {
        let from_str = HttpHeader::new("a", "b");
        let from_string = HttpHeader::new(String::from("a"), String::from("b"));
        assert_eq!(from_str, from_string);
    }
    // =========================================================================
    // HttpRequestConfig builders (`constructor` category)
    // =========================================================================
    #[test]
    fn config_new_matches_default_and_documented_values() {
        let a = HttpRequestConfig::new();
        let b = HttpRequestConfig::default();
        assert_eq!(a.timeout_secs, b.timeout_secs);
        assert_eq!(a.max_response_size, b.max_response_size);
        assert_eq!(a.user_agent.as_str(), b.user_agent.as_str());
        assert_eq!(a.headers.len(), b.headers.len());
        assert_eq!(
            a.disable_tls_cert_verification,
            b.disable_tls_cert_verification
        );
        assert_eq!(a.timeout_secs, 30);
        assert_eq!(a.max_response_size, 100 * 1024 * 1024);
        assert!(a.headers.is_empty());
        // Secure by default: certificate verification must be ON unless opted out.
        assert!(!a.disable_tls_cert_verification);
    }
    #[test]
    fn with_timeout_stores_extremes_verbatim() {
        for secs in [0_u64, 1, 30, u64::MAX / 2, u64::MAX - 1, u64::MAX] {
            let cfg = HttpRequestConfig::new().with_timeout(secs);
            assert_eq!(cfg.timeout_secs, secs);
            // Nothing else may be disturbed by the setter.
            assert_eq!(cfg.max_response_size, 100 * 1024 * 1024);
            assert!(cfg.headers.is_empty());
        }
    }
    #[test]
    fn with_max_size_stores_extremes_verbatim() {
        for max in [0_u64, 1, u64::MAX] {
            let cfg = HttpRequestConfig::new().with_max_size(max);
            assert_eq!(cfg.max_response_size, max);
            assert_eq!(cfg.timeout_secs, 30);
        }
        // 0 is the documented "unlimited" sentinel, not a "reject everything" limit.
        assert_eq!(HttpRequestConfig::new().with_max_size(0).max_response_size, 0);
    }
    #[test]
    fn builder_setters_are_last_write_wins_and_independent() {
        let cfg = HttpRequestConfig::new()
            .with_timeout(1)
            .with_timeout(u64::MAX)
            .with_max_size(5)
            .with_max_size(0)
            .with_user_agent("first")
            .with_user_agent("second");
        assert_eq!(cfg.timeout_secs, u64::MAX);
        assert_eq!(cfg.max_response_size, 0);
        assert_eq!(cfg.user_agent.as_str(), "second");
    }
    #[test]
    fn with_user_agent_accepts_empty_and_extreme_values() {
        let empty = HttpRequestConfig::new().with_user_agent("");
        // Empty UA is meaningful: http_get_with_config skips the header entirely.
        assert!(empty.user_agent.as_str().is_empty());
        let unicode = HttpRequestConfig::new().with_user_agent(NASTY);
        assert_eq!(unicode.user_agent.as_str(), NASTY);
        let big = huge_ascii();
        let huge = HttpRequestConfig::new().with_user_agent(big.clone());
        assert_eq!(huge.user_agent.as_str().len(), big.len());
    }
    #[test]
    fn with_header_appends_in_order_and_keeps_duplicates() {
        let mut cfg = HttpRequestConfig::new();
        assert!(cfg.headers.is_empty());
        for i in 0..100_usize {
            cfg = cfg.with_header(format!("H{i}"), format!("v{i}"));
        }
        // Duplicate names are kept, not deduplicated or overwritten.
        cfg = cfg.with_header("H0", "second-value");
        assert_eq!(cfg.headers.len(), 101);
        let slice = cfg.headers.as_slice();
        for (i, h) in slice.iter().take(100).enumerate() {
            assert_eq!(h.name.as_str(), format!("H{i}"));
            assert_eq!(h.value.as_str(), format!("v{i}"));
        }
        assert_eq!(slice[100].name.as_str(), "H0");
        assert_eq!(slice[100].value.as_str(), "second-value");
    }
    #[test]
    fn with_header_accepts_empty_name_and_value() {
        let cfg = HttpRequestConfig::new().with_header("", "");
        assert_eq!(cfg.headers.len(), 1);
        assert!(cfg.headers.as_slice()[0].name.as_str().is_empty());
        assert!(cfg.headers.as_slice()[0].value.as_str().is_empty());
    }
    #[test]
    fn cloning_a_config_gives_an_independent_header_vec() {
        // The header vec is an FFI vec with a destructor field; a shallow clone that
        // aliased the original's buffer would show up here (and later double-free).
        let base = HttpRequestConfig::new().with_header("A", "1");
        let cloned = base.clone().with_header("B", "2");
        assert_eq!(base.headers.len(), 1);
        assert_eq!(cloned.headers.len(), 2);
        assert_eq!(base.headers.as_slice()[0].name.as_str(), "A");
        assert_eq!(cloned.headers.as_slice()[0].name.as_str(), "A");
        assert_eq!(cloned.headers.as_slice()[1].name.as_str(), "B");
        drop(cloned);
        // `base` must still be readable after the clone is dropped.
        assert_eq!(base.headers.as_slice()[0].value.as_str(), "1");
    }
    // =========================================================================
    // HttpResponse predicates (`predicate` category)
    // =========================================================================
    #[test]
    fn status_predicates_at_class_boundaries() {
        let cases: &[(u16, bool, bool, bool, bool)] = &[
            // status, success, redirect, client_err, server_err
            (0, false, false, false, false),
            (100, false, false, false, false),
            (199, false, false, false, false),
            (200, true, false, false, false),
            (204, true, false, false, false),
            (299, true, false, false, false),
            (300, false, true, false, false),
            (399, false, true, false, false),
            (400, false, false, true, false),
            (499, false, false, true, false),
            (500, false, false, false, true),
            (599, false, false, false, true),
            (600, false, false, false, false),
            (999, false, false, false, false),
            (u16::MAX, false, false, false, false),
        ];
        for &(status, success, redirect, client, server) in cases {
            let r = response_with_status(status);
            assert_eq!(r.is_success(), success, "is_success({status})");
            assert_eq!(r.is_redirect(), redirect, "is_redirect({status})");
            assert_eq!(r.is_client_error(), client, "is_client_error({status})");
            assert_eq!(r.is_server_error(), server, "is_server_error({status})");
        }
    }
    #[test]
    fn status_predicates_are_mutually_exclusive_over_the_whole_u16_range() {
        let mut r = response_with_status(0);
        for status in 0..=u16::MAX {
            r.status_code = status;
            let hits = u8::from(r.is_success())
                + u8::from(r.is_redirect())
                + u8::from(r.is_client_error())
                + u8::from(r.is_server_error());
            assert!(hits <= 1, "status {status} matched {hits} classes");
            // Exactly one class must match inside 200..=599, and none outside it.
            let expected = u8::from((200_u16..600_u16).contains(&status));
            assert_eq!(hits, expected, "status {status}");
        }
    }
    #[test]
    fn status_predicates_ignore_body_and_headers() {
        let mut r = response_with_body(vec![0xFF; 1024]);
        r.status_code = 503;
        r.content_length = u64::MAX;
        r.headers = HttpHeaderVec::from_vec(vec![HttpHeader::new("X", "Y")]);
        assert!(r.is_server_error());
        assert!(!r.is_success());
    }
    // =========================================================================
    // HttpResponse::body_as_string (`getter` category)
    // =========================================================================
    #[test]
    fn body_as_string_on_empty_body_is_some_empty_string() {
        let r = response_with_body(Vec::new());
        let s = r.body_as_string().expect("empty body is valid UTF-8");
        assert_eq!(s.as_str(), "");
    }
    #[test]
    fn body_as_string_round_trips_valid_utf8() {
        for text in ["hello", NASTY, "🦀🦀🦀", "a\u{0}b"] {
            let r = response_with_body(text.as_bytes().to_vec());
            let s = r.body_as_string().expect("valid UTF-8 must decode");
            assert_eq!(s.as_str(), text);
            assert_eq!(s.as_str().len(), text.len());
        }
    }
    #[test]
    fn body_as_string_returns_none_for_invalid_utf8() {
        let invalid: &[&[u8]] = &[
            &[0xFF],                    // never valid
            &[0x80],                    // lone continuation byte
            &[0xC3],                    // truncated 2-byte sequence
            &[0xE2, 0x82],              // truncated 3-byte sequence
            &[0xED, 0xA0, 0x80],        // UTF-16 surrogate half (CESU-8)
            &[0xF4, 0x90, 0x80, 0x80],  // above U+10FFFF
            &[0xC0, 0x80],              // overlong NUL
            &[b'o', b'k', 0xFE, b'!'],  // valid prefix, invalid tail
        ];
        for bytes in invalid {
            let r = response_with_body(bytes.to_vec());
            assert!(
                r.body_as_string().is_none(),
                "expected None for {bytes:02X?}"
            );
        }
    }
    #[test]
    fn body_as_string_is_pure_and_repeatable() {
        let r = response_with_body(b"payload".to_vec());
        let first = r.body_as_string();
        let second = r.body_as_string();
        assert_eq!(first, second);
        // The getter must not consume or mutate the body.
        assert_eq!(r.body.as_slice(), &b"payload"[..]);
    }
    #[test]
    fn body_as_string_ignores_a_lying_content_length() {
        // content_length is untrusted server metadata and is not an invariant of
        // `body`; the decoder must go by the actual byte slice.
        let mut r = response_with_body(b"1234".to_vec());
        r.content_length = u64::MAX;
        assert_eq!(r.body_as_string().expect("valid").as_str(), "1234");
        r.content_length = 0;
        assert_eq!(r.body_as_string().expect("valid").as_str(), "1234");
    }
    #[test]
    fn body_as_string_handles_a_large_body() {
        let big = huge_ascii();
        let r = response_with_body(big.clone().into_bytes());
        let s = r.body_as_string().expect("ASCII is valid UTF-8");
        assert_eq!(s.as_str().len(), big.len());
    }
    // =========================================================================
    // FFI result round-trips (encode == decode)
    // =========================================================================
    #[test]
    fn result_http_response_round_trips_through_the_ffi_enum() {
        let ok: Result<HttpResponse, HttpError> = Ok(response_with_status(200));
        let ffi: ResultHttpResponseHttpError = ok.clone().into();
        assert!(ffi.is_ok());
        assert!(!ffi.is_err());
        assert_eq!(ffi.into_result(), ok);
        let err: Result<HttpResponse, HttpError> =
            Err(HttpError::http_status(u16::MAX, AzString::from(NASTY)));
        let ffi: ResultHttpResponseHttpError = err.clone().into();
        assert!(ffi.is_err());
        assert!(!ffi.is_ok());
        assert_eq!(ffi.into_result(), err);
    }
    #[test]
    fn result_u8vec_round_trips_through_the_ffi_enum() {
        let ok: Result<U8Vec, HttpError> = Ok(U8Vec::from(vec![0u8, 0xFF, 0x7F]));
        let ffi: ResultU8VecHttpError = ok.clone().into();
        assert!(ffi.is_ok());
        assert_eq!(ffi.into_result(), ok);
        let err: Result<U8Vec, HttpError> = Err(HttpError::response_too_large(0, u64::MAX));
        let ffi: ResultU8VecHttpError = err.clone().into();
        assert!(ffi.is_err());
        assert_eq!(ffi.into_result(), err);
        // An empty Ok payload must stay Ok — not collapse into Err.
        let empty: ResultU8VecHttpError = Ok(U8Vec::from(Vec::new())).into();
        assert!(empty.is_ok());
        assert_eq!(empty.as_result().map(|v| v.len()), Ok(0));
    }
    #[test]
    fn ffi_result_as_result_agrees_with_is_ok() {
        let ffi: ResultHttpResponseHttpError = Ok(response_with_status(404)).into();
        assert_eq!(ffi.is_ok(), ffi.as_result().is_ok());
        assert_eq!(
            ffi.as_result().map(HttpResponse::is_client_error),
            Ok(true)
        );
    }
    // =========================================================================
    // `http` feature DISABLED — the stubs must fail closed
    // =========================================================================
    #[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
    #[test]
    fn stub_free_functions_return_err_for_any_url() {
        for url in ["", "https://example.com", NASTY, huge_ascii().as_str()] {
            let cfg = HttpRequestConfig::new();
            assert!(matches!(http_get(url), Err(HttpError::Other(_))));
            assert!(matches!(
                http_get_with_config(url, &cfg),
                Err(HttpError::Other(_))
            ));
            assert!(matches!(download_bytes(url), Err(HttpError::Other(_))));
            assert!(matches!(
                download_bytes_with_config(url, &cfg),
                Err(HttpError::Other(_))
            ));
        }
    }
    #[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
    #[test]
    fn stub_is_url_reachable_is_always_false() {
        // Fails closed: a disabled HTTP stack must never claim a URL is reachable.
        for url in ["", "https://example.com", NASTY, huge_ascii().as_str()] {
            assert!(!is_url_reachable(url));
            assert!(!HttpRequestConfig::is_url_reachable(AzString::from(url)));
        }
    }
    #[cfg(any(not(feature = "http"), target_arch = "wasm32"))]
    #[test]
    fn stub_config_methods_return_err_results() {
        let cfg = HttpRequestConfig::new().with_timeout(u64::MAX).with_max_size(0);
        for url in ["", "https://example.com", NASTY] {
            let u = AzString::from(url);
            assert!(HttpRequestConfig::http_get_default(u.clone()).is_err());
            assert!(cfg.http_get(u.clone()).is_err());
            assert!(HttpRequestConfig::download_bytes_default(u.clone()).is_err());
            assert!(cfg.download_bytes(u.clone()).is_err());
        }
    }
    // =========================================================================
    // `http` feature ENABLED — offline-only checks
    // =========================================================================
    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]
    #[test]
    fn make_agent_builds_at_timeout_extremes() {
        // Duration::from_secs(u64::MAX) is representable, so agent construction must
        // not panic at either end of the range (the agent is never called here).
        for secs in [0_u64, 1, 30, u64::MAX] {
            for disable_tls in [false, true] {
                let _agent = make_agent(secs, disable_tls);
            }
        }
    }
    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]
    #[test]
    fn malformed_urls_are_rejected_without_touching_the_network() {
        // Each of these fails in ureq's URI parser: no DNS resolution, no socket.
        let cfg = HttpRequestConfig::new().with_timeout(1);
        for url in ["", "not a url", "://no-scheme", "ht tp://spaces"] {
            assert!(http_get(url).is_err(), "expected Err for {url:?}");
            assert!(
                http_get_with_config(url, &cfg).is_err(),
                "expected Err for {url:?}"
            );
            assert!(download_bytes(url).is_err(), "expected Err for {url:?}");
            assert!(!is_url_reachable(url), "expected false for {url:?}");
        }
    }
    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]
    #[test]
    fn malformed_urls_are_rejected_through_the_ffi_wrappers() {
        let cfg = HttpRequestConfig::new().with_timeout(1);
        for url in ["", "not a url"] {
            let u = AzString::from(url);
            assert!(HttpRequestConfig::http_get_default(u.clone()).is_err());
            assert!(cfg.http_get(u.clone()).is_err());
            assert!(HttpRequestConfig::download_bytes_default(u.clone()).is_err());
            assert!(cfg.download_bytes(u.clone()).is_err());
            assert!(!HttpRequestConfig::is_url_reachable(u.clone()));
        }
    }
}