Skip to main content

ironflow_core/operations/
http.rs

1//! Http operation - perform HTTP requests with timeout and header control.
2//!
3//! The [`Http`] builder sends an HTTP request via [`reqwest`], captures the
4//! response, and returns an [`HttpOutput`] on success. It implements
5//! [`IntoFuture`] so you can `await` it directly:
6//!
7//! ```no_run
8//! use ironflow_core::operations::http::Http;
9//!
10//! # async fn example() -> Result<(), ironflow_core::error::OperationError> {
11//! let output = Http::get("https://httpbin.org/get").await?;
12//! println!("status: {}", output.status());
13//! # Ok(())
14//! # }
15//! ```
16
17use std::collections::HashMap;
18use std::future::{Future, IntoFuture};
19use std::net::IpAddr;
20use std::pin::Pin;
21use std::time::{Duration, Instant};
22
23use reqwest::{Client, Method};
24use serde::de::DeserializeOwned;
25use serde_json::Value;
26use std::sync::LazyLock;
27use tokio::time;
28use tracing::{debug, warn};
29use url::Url;
30
31use crate::retry::RetryPolicy;
32use crate::trace_context::WorkflowTraceContext;
33
34/// Default timeout for HTTP requests (30 seconds).
35const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
36
37use crate::error::OperationError;
38#[cfg(feature = "prometheus")]
39use crate::metric_names;
40use crate::utils::MAX_OUTPUT_SIZE;
41
42/// Returns `true` if the given IP address is private, loopback, link-local,
43/// or a cloud metadata endpoint - i.e. a target that should not be reachable
44/// via SSRF.
45fn is_blocked_ip(ip: IpAddr) -> bool {
46    match ip {
47        IpAddr::V4(v4) => {
48            v4.is_loopback()              // 127.0.0.0/8
49                || v4.is_private()         // 10/8, 172.16/12, 192.168/16
50                || v4.is_link_local()      // 169.254.0.0/16 (includes AWS metadata)
51                || v4.is_broadcast()       // 255.255.255.255
52                || v4.is_unspecified() // 0.0.0.0
53        }
54        IpAddr::V6(v6) => {
55            v6.is_loopback()              // ::1
56                || v6.is_unspecified() // ::
57        }
58    }
59}
60
61/// Check if a URL host is a blocked IP address (literal IP in the URL).
62/// Returns an error message if blocked, None if safe (or if host is a hostname
63/// that needs DNS resolution - runtime check happens at connect time).
64fn check_url_host(raw: &str) -> Option<String> {
65    let parsed = Url::parse(raw).ok()?;
66    let host_str = parsed.host_str()?;
67
68    let host_clean = host_str.trim_start_matches('[').trim_end_matches(']');
69
70    if let Ok(ip) = host_clean.parse::<IpAddr>()
71        && is_blocked_ip(ip)
72    {
73        return Some(format!(
74            "URL targets a blocked IP address ({ip}): private, loopback, and link-local addresses are not allowed"
75        ));
76    }
77    None
78}
79
80static HTTP_CLIENT: LazyLock<Client> = LazyLock::new(|| {
81    Client::builder()
82        .redirect(reqwest::redirect::Policy::none())
83        .build()
84        .expect("failed to build HTTP client")
85});
86
87/// Builder for executing an HTTP request.
88///
89/// Supports method, URL, headers, body (JSON or text), and timeout.
90/// The response body is captured as a string, with optional typed
91/// JSON deserialization via [`HttpOutput::json`].
92///
93/// Unlike [`Shell`](crate::operations::shell::Shell), `Http` does **not**
94/// fail on non-2xx status codes - use [`HttpOutput::is_success`] to check.
95/// Only transport-level errors (DNS, timeout, connection refused) produce
96/// an [`OperationError::Http`].
97///
98/// # Examples
99///
100/// ```no_run
101/// use std::time::Duration;
102/// use ironflow_core::operations::http::Http;
103///
104/// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
105/// let output = Http::post("https://httpbin.org/post")
106///     .header("Authorization", "Bearer token123")
107///     .json(serde_json::json!({"key": "value"}))
108///     .timeout(Duration::from_secs(30))
109///     .await?;
110///
111/// println!("status: {}, body: {}", output.status(), output.body());
112/// # Ok(())
113/// # }
114/// ```
115#[must_use = "an Http request does nothing until .run() or .await is called"]
116pub struct Http {
117    method: Method,
118    url: String,
119    headers: HashMap<String, String>,
120    body: Option<HttpBody>,
121    timeout: Option<Duration>,
122    max_response_size: usize,
123    dry_run: Option<bool>,
124    retry_policy: Option<RetryPolicy>,
125}
126
127enum HttpBody {
128    Text(String),
129    Json(Value),
130}
131
132impl Http {
133    /// Create a request builder with an arbitrary HTTP method.
134    ///
135    /// # Panics
136    ///
137    /// Panics if `url` is empty.
138    pub fn new(method: Method, url: &str) -> Self {
139        let trimmed = url.trim();
140        assert!(!trimmed.is_empty(), "url must not be empty");
141        assert!(
142            trimmed.starts_with("http://") || trimmed.starts_with("https://"),
143            "url must use http:// or https:// scheme, got: {trimmed}"
144        );
145        Self {
146            method,
147            url: trimmed.to_string(),
148            headers: HashMap::new(),
149            body: None,
150            timeout: Some(DEFAULT_HTTP_TIMEOUT),
151            max_response_size: MAX_OUTPUT_SIZE,
152            dry_run: None,
153            retry_policy: None,
154        }
155    }
156
157    /// Create a GET request builder.
158    ///
159    /// # Examples
160    ///
161    /// ```no_run
162    /// use ironflow_core::operations::http::Http;
163    ///
164    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
165    /// let output = Http::get("https://httpbin.org/get").await?;
166    /// # Ok(())
167    /// # }
168    /// ```
169    pub fn get(url: &str) -> Self {
170        Self::new(Method::GET, url)
171    }
172
173    /// Create a POST request builder.
174    pub fn post(url: &str) -> Self {
175        Self::new(Method::POST, url)
176    }
177
178    /// Create a PUT request builder.
179    pub fn put(url: &str) -> Self {
180        Self::new(Method::PUT, url)
181    }
182
183    /// Create a PATCH request builder.
184    pub fn patch(url: &str) -> Self {
185        Self::new(Method::PATCH, url)
186    }
187
188    /// Create a DELETE request builder.
189    pub fn delete(url: &str) -> Self {
190        Self::new(Method::DELETE, url)
191    }
192
193    /// Add a header to the request.
194    ///
195    /// Can be called multiple times to set several headers.
196    pub fn header(mut self, key: &str, value: &str) -> Self {
197        self.headers.insert(key.to_string(), value.to_string());
198        self
199    }
200
201    /// Set a JSON body.
202    ///
203    /// `Content-Type: application/json` is added automatically by reqwest.
204    /// Takes ownership of the [`Value`] to avoid cloning.
205    pub fn json(mut self, value: Value) -> Self {
206        self.body = Some(HttpBody::Json(value));
207        self
208    }
209
210    /// Set a plain text body.
211    pub fn text(mut self, body: &str) -> Self {
212        self.body = Some(HttpBody::Text(body.to_string()));
213        self
214    }
215
216    /// Override the timeout for the request.
217    ///
218    /// If the request does not complete within this duration, an
219    /// [`OperationError::Http`] is returned. Defaults to 30 seconds.
220    pub fn timeout(mut self, timeout: Duration) -> Self {
221        self.timeout = Some(timeout);
222        self
223    }
224
225    /// Set the maximum allowed response body size in bytes.
226    ///
227    /// If the response body exceeds this limit, an [`OperationError::Http`] is
228    /// returned. Defaults to 10 MiB.
229    pub fn max_response_size(mut self, bytes: usize) -> Self {
230        self.max_response_size = bytes;
231        self
232    }
233
234    /// Retry the request up to `max_retries` times on transient failures.
235    ///
236    /// Uses default exponential backoff settings (200ms initial, 2x multiplier,
237    /// 30s cap). For custom backoff parameters, use [`retry_policy`](Http::retry_policy).
238    ///
239    /// Only transient errors are retried: transport errors (DNS, timeout,
240    /// connection refused) and responses with status 5xx or 429. Client errors
241    /// (4xx except 429) and SSRF blocks are never retried.
242    ///
243    /// # Panics
244    ///
245    /// Panics if `max_retries` is `0`.
246    ///
247    /// # Examples
248    ///
249    /// ```no_run
250    /// use ironflow_core::operations::http::Http;
251    ///
252    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
253    /// let output = Http::get("https://api.example.com/data")
254    ///     .retry(3)
255    ///     .await?;
256    /// # Ok(())
257    /// # }
258    /// ```
259    pub fn retry(mut self, max_retries: u32) -> Self {
260        self.retry_policy = Some(RetryPolicy::new(max_retries));
261        self
262    }
263
264    /// Set a custom [`RetryPolicy`] for this request.
265    ///
266    /// Allows full control over backoff duration, multiplier, and max delay.
267    /// See [`RetryPolicy`] for details.
268    ///
269    /// # Examples
270    ///
271    /// ```no_run
272    /// use std::time::Duration;
273    /// use ironflow_core::operations::http::Http;
274    /// use ironflow_core::retry::RetryPolicy;
275    ///
276    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
277    /// let output = Http::get("https://api.example.com/data")
278    ///     .retry_policy(
279    ///         RetryPolicy::new(5)
280    ///             .backoff(Duration::from_millis(500))
281    ///             .max_backoff(Duration::from_secs(60))
282    ///             .multiplier(3.0)
283    ///     )
284    ///     .await?;
285    /// # Ok(())
286    /// # }
287    /// ```
288    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
289        self.retry_policy = Some(policy);
290        self
291    }
292
293    /// Attach a [`WorkflowTraceContext`] to this request.
294    ///
295    /// When set, the `traceparent` header is automatically injected into
296    /// the request using the context's [`to_traceparent`](WorkflowTraceContext::to_traceparent)
297    /// value. This enables distributed tracing correlation with downstream
298    /// services.
299    ///
300    /// # Examples
301    ///
302    /// ```no_run
303    /// use ironflow_core::operations::http::Http;
304    /// use ironflow_core::trace_context::WorkflowTraceContext;
305    ///
306    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
307    /// let ctx = WorkflowTraceContext::new_root();
308    /// let output = Http::get("https://api.example.com/data")
309    ///     .trace_context(&ctx)
310    ///     .await?;
311    /// # Ok(())
312    /// # }
313    /// ```
314    pub fn trace_context(self, ctx: &WorkflowTraceContext) -> Self {
315        self.header("traceparent", &ctx.to_traceparent())
316    }
317
318    /// Enable or disable dry-run mode for this specific operation.
319    ///
320    /// When dry-run is active, the request is logged but not sent.
321    /// A synthetic [`HttpOutput`] is returned with status 200, empty body,
322    /// and 0ms duration.
323    ///
324    /// If not set, falls back to the global dry-run setting
325    /// (see [`set_dry_run`](crate::dry_run::set_dry_run)).
326    pub fn dry_run(mut self, enabled: bool) -> Self {
327        self.dry_run = Some(enabled);
328        self
329    }
330
331    /// Execute the HTTP request.
332    ///
333    /// If a [`retry_policy`](Http::retry_policy) is configured, transient
334    /// failures (transport errors, 5xx, 429) are retried with exponential
335    /// backoff. Non-retryable errors and successful responses are returned
336    /// immediately.
337    ///
338    /// # Errors
339    ///
340    /// Returns [`OperationError::Http`] if the request fails at the transport
341    /// layer (network error, DNS failure, timeout) or if the response body
342    /// cannot be read. Non-2xx status codes are **not** treated as errors.
343    #[tracing::instrument(name = "http", skip_all, fields(method = %self.method, url = %self.url))]
344    pub async fn run(self) -> Result<HttpOutput, OperationError> {
345        if crate::dry_run::effective_dry_run(self.dry_run) {
346            debug!(method = %self.method, url = %self.url, "[dry-run] http request skipped");
347            return Ok(HttpOutput {
348                status: 200,
349                headers: HashMap::new(),
350                body: String::new(),
351                duration_ms: 0,
352            });
353        }
354
355        if let Some(reason) = check_url_host(&self.url) {
356            return Err(OperationError::Http {
357                status: None,
358                message: reason,
359            });
360        }
361
362        let result = self.execute_once().await;
363
364        let policy = match &self.retry_policy {
365            Some(p) => p,
366            None => return result,
367        };
368
369        // If the first attempt succeeded with a non-retryable status, return it.
370        // If it failed with a non-retryable error, return it.
371        match &result {
372            Ok(output) if !crate::retry::is_retryable_status(output.status) => return result,
373            Err(err) if !crate::retry::is_retryable(err) => return result,
374            _ => {}
375        }
376
377        let mut last_result = result;
378
379        for attempt in 0..policy.max_retries {
380            let delay = policy.delay_for_attempt(attempt);
381            warn!(
382                attempt = attempt + 1,
383                max_retries = policy.max_retries,
384                delay_ms = delay.as_millis() as u64,
385                "retrying http request"
386            );
387            time::sleep(delay).await;
388
389            last_result = self.execute_once().await;
390
391            match &last_result {
392                Ok(output) if !crate::retry::is_retryable_status(output.status) => {
393                    return last_result;
394                }
395                Err(err) if !crate::retry::is_retryable(err) => return last_result,
396                _ => {}
397            }
398        }
399
400        last_result
401    }
402
403    /// Execute a single HTTP request attempt (no retry logic).
404    async fn execute_once(&self) -> Result<HttpOutput, OperationError> {
405        debug!(method = %self.method, url = %self.url, "executing http request");
406        let start = Instant::now();
407
408        #[cfg(feature = "prometheus")]
409        let method_label = self.method.to_string();
410
411        let mut builder = HTTP_CLIENT.request(self.method.clone(), &self.url);
412
413        if let Some(timeout) = self.timeout {
414            builder = builder.timeout(timeout);
415        }
416
417        for (k, v) in &self.headers {
418            builder = builder.header(k.as_str(), v.as_str());
419        }
420
421        match &self.body {
422            Some(HttpBody::Json(v)) => {
423                builder = builder.json(v);
424            }
425            Some(HttpBody::Text(t)) => {
426                builder = builder.body(t.clone());
427            }
428            None => {}
429        }
430
431        let response = match builder.send().await {
432            Ok(resp) => resp,
433            Err(e) => {
434                #[cfg(feature = "prometheus")]
435                {
436                    metrics::counter!(metric_names::HTTP_TOTAL, "method" => method_label, "status" => metric_names::STATUS_ERROR).increment(1);
437                }
438                return Err(OperationError::Http {
439                    status: None,
440                    message: format!("request failed: {e}"),
441                });
442            }
443        };
444
445        let status = response.status().as_u16();
446        let headers: HashMap<String, String> = response
447            .headers()
448            .iter()
449            .map(|(k, v)| {
450                let val = match v.to_str() {
451                    Ok(s) => s.to_string(),
452                    Err(_) => {
453                        debug!(header = %k, "non-UTF-8 header value, replacing with empty string");
454                        String::new()
455                    }
456                };
457                (k.to_string(), val)
458            })
459            .collect();
460        let max_response_size = self.max_response_size;
461        let response_too_large = |size: usize, limit: usize| OperationError::Http {
462            status: Some(status),
463            message: format!(
464                "response body too large: {size} bytes exceeds limit of {limit} bytes"
465            ),
466        };
467
468        if let Some(cl) = response.content_length() {
469            let content_length = usize::try_from(cl).unwrap_or(usize::MAX);
470            if content_length > max_response_size {
471                return Err(response_too_large(content_length, max_response_size));
472            }
473        }
474
475        let mut body_bytes = Vec::new();
476        let mut response = response;
477        loop {
478            match response.chunk().await {
479                Ok(Some(chunk)) => {
480                    if body_bytes.len() + chunk.len() > max_response_size {
481                        return Err(response_too_large(
482                            body_bytes.len() + chunk.len(),
483                            max_response_size,
484                        ));
485                    }
486                    body_bytes.extend_from_slice(&chunk);
487                }
488                Ok(None) => break,
489                Err(e) => {
490                    return Err(OperationError::Http {
491                        status: Some(status),
492                        message: format!("failed to read response body: {e}"),
493                    });
494                }
495            }
496        }
497
498        let body = String::from_utf8_lossy(&body_bytes).into_owned();
499        let duration_ms = start.elapsed().as_millis() as u64;
500
501        debug!(
502            status,
503            body_len = body.len(),
504            duration_ms,
505            "http request completed"
506        );
507
508        #[cfg(feature = "prometheus")]
509        {
510            let status_label = status.to_string();
511            metrics::counter!(metric_names::HTTP_TOTAL, "method" => method_label, "status" => status_label).increment(1);
512            metrics::histogram!(metric_names::HTTP_DURATION_SECONDS)
513                .record(duration_ms as f64 / 1000.0);
514        }
515
516        Ok(HttpOutput {
517            status,
518            headers,
519            body,
520            duration_ms,
521        })
522    }
523}
524
525impl IntoFuture for Http {
526    type Output = Result<HttpOutput, OperationError>;
527    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
528
529    fn into_future(self) -> Self::IntoFuture {
530        Box::pin(self.run())
531    }
532}
533
534/// Output of a completed HTTP request.
535///
536/// Contains the status code, response headers, body, and duration.
537#[derive(Debug)]
538pub struct HttpOutput {
539    status: u16,
540    headers: HashMap<String, String>,
541    body: String,
542    duration_ms: u64,
543}
544
545impl HttpOutput {
546    /// Return the HTTP status code (e.g. `200`, `404`).
547    pub fn status(&self) -> u16 {
548        self.status
549    }
550
551    /// Return the response headers as a string map.
552    pub fn headers(&self) -> &HashMap<String, String> {
553        &self.headers
554    }
555
556    /// Return the response body as text.
557    pub fn body(&self) -> &str {
558        &self.body
559    }
560
561    /// Deserialize the response body as JSON into the given type `T`.
562    ///
563    /// # Errors
564    ///
565    /// Returns [`OperationError::Deserialize`] if parsing fails.
566    pub fn json<T: DeserializeOwned>(&self) -> Result<T, OperationError> {
567        serde_json::from_str(&self.body).map_err(OperationError::deserialize::<T>)
568    }
569
570    /// Return the wall-clock duration of the request in milliseconds.
571    pub fn duration_ms(&self) -> u64 {
572        self.duration_ms
573    }
574
575    /// Return `true` if the status code is in the 2xx range.
576    pub fn is_success(&self) -> bool {
577        (200..300).contains(&self.status)
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn get_builder_sets_method_and_url() {
587        let http = Http::get("https://example.com");
588        assert_eq!(http.method, Method::GET);
589        assert_eq!(http.url, "https://example.com");
590    }
591
592    #[test]
593    fn post_builder_sets_method() {
594        let http = Http::post("https://example.com");
595        assert_eq!(http.method, Method::POST);
596    }
597
598    #[test]
599    fn put_builder_sets_method() {
600        assert_eq!(Http::put("https://x.com").method, Method::PUT);
601    }
602
603    #[test]
604    fn patch_builder_sets_method() {
605        assert_eq!(Http::patch("https://x.com").method, Method::PATCH);
606    }
607
608    #[test]
609    fn delete_builder_sets_method() {
610        assert_eq!(Http::delete("https://x.com").method, Method::DELETE);
611    }
612
613    #[test]
614    fn header_builder_stores_headers() {
615        let http = Http::get("https://x.com")
616            .header("Authorization", "Bearer token")
617            .header("Accept", "application/json");
618        assert_eq!(http.headers.get("Authorization").unwrap(), "Bearer token");
619        assert_eq!(http.headers.get("Accept").unwrap(), "application/json");
620    }
621
622    #[test]
623    fn timeout_builder_stores_duration() {
624        let http = Http::get("https://x.com").timeout(Duration::from_secs(60));
625        assert_eq!(http.timeout, Some(Duration::from_secs(60)));
626    }
627
628    #[test]
629    fn default_timeout_is_30_seconds() {
630        let http = Http::get("https://x.com");
631        assert_eq!(http.timeout, Some(DEFAULT_HTTP_TIMEOUT));
632    }
633
634    #[test]
635    fn http_output_is_success_for_2xx() {
636        for status in [200, 201, 202, 204, 299] {
637            let output = HttpOutput {
638                status,
639                headers: HashMap::new(),
640                body: String::new(),
641                duration_ms: 0,
642            };
643            assert!(output.is_success(), "expected {status} to be success");
644        }
645    }
646
647    #[test]
648    fn http_output_is_not_success_for_non_2xx() {
649        for status in [100, 301, 400, 401, 403, 404, 500, 503] {
650            let output = HttpOutput {
651                status,
652                headers: HashMap::new(),
653                body: String::new(),
654                duration_ms: 0,
655            };
656            assert!(!output.is_success(), "expected {status} to not be success");
657        }
658    }
659
660    #[test]
661    fn http_output_json_parses_valid_json() {
662        let output = HttpOutput {
663            status: 200,
664            headers: HashMap::new(),
665            body: r#"{"name":"test","count":42}"#.to_string(),
666            duration_ms: 0,
667        };
668        let parsed: serde_json::Value = output.json().unwrap();
669        assert_eq!(parsed["name"], "test");
670        assert_eq!(parsed["count"], 42);
671    }
672
673    #[test]
674    fn http_output_json_fails_on_invalid_json() {
675        let output = HttpOutput {
676            status: 200,
677            headers: HashMap::new(),
678            body: "not json".to_string(),
679            duration_ms: 0,
680        };
681        let err = output.json::<serde_json::Value>().unwrap_err();
682        assert!(matches!(err, OperationError::Deserialize { .. }));
683    }
684
685    #[test]
686    #[should_panic(expected = "url must not be empty")]
687    fn empty_url_panics() {
688        let _ = Http::get("");
689    }
690
691    #[test]
692    #[should_panic(expected = "url must not be empty")]
693    fn whitespace_url_panics() {
694        let _ = Http::post("   ");
695    }
696
697    #[test]
698    #[should_panic(expected = "url must use http:// or https://")]
699    fn non_http_scheme_panics() {
700        let _ = Http::get("file:///etc/passwd");
701    }
702
703    #[test]
704    #[should_panic(expected = "url must use http:// or https://")]
705    fn ftp_scheme_panics() {
706        let _ = Http::get("ftp://example.com");
707    }
708
709    #[tokio::test]
710    async fn ssrf_localhost_blocked() {
711        let err = Http::get("http://127.0.0.1/secret")
712            .run()
713            .await
714            .unwrap_err();
715        assert!(err.to_string().contains("blocked IP address"));
716    }
717
718    #[tokio::test]
719    async fn ssrf_metadata_blocked() {
720        let err = Http::get("http://169.254.169.254/latest/meta-data/")
721            .run()
722            .await
723            .unwrap_err();
724        assert!(err.to_string().contains("blocked IP address"));
725    }
726
727    #[tokio::test]
728    async fn ssrf_private_10_blocked() {
729        let err = Http::get("http://10.0.0.1/internal")
730            .run()
731            .await
732            .unwrap_err();
733        assert!(err.to_string().contains("blocked IP address"));
734    }
735
736    #[tokio::test]
737    async fn ssrf_ipv6_loopback_blocked() {
738        let err = Http::get("http://[::1]/secret").run().await.unwrap_err();
739        assert!(err.to_string().contains("blocked IP address"));
740    }
741
742    #[test]
743    fn ssrf_public_ip_allowed() {
744        // Should not panic at construction time
745        let _ = Http::get("http://8.8.8.8/dns");
746    }
747
748    #[test]
749    fn ssrf_hostname_allowed() {
750        // Hostnames are not blocked at URL parse time (would need DNS)
751        let _ = Http::get("https://example.com/api");
752    }
753
754    #[tokio::test]
755    async fn ssrf_172_16_blocked() {
756        let err = Http::get("http://172.16.0.1/internal")
757            .run()
758            .await
759            .unwrap_err();
760        assert!(err.to_string().contains("blocked IP address"));
761    }
762
763    #[tokio::test]
764    async fn ssrf_192_168_blocked() {
765        let err = Http::get("http://192.168.1.1/admin")
766            .run()
767            .await
768            .unwrap_err();
769        assert!(err.to_string().contains("blocked IP address"));
770    }
771
772    #[tokio::test]
773    async fn ssrf_unspecified_blocked() {
774        let err = Http::get("http://0.0.0.0/").run().await.unwrap_err();
775        assert!(err.to_string().contains("blocked IP address"));
776    }
777
778    #[tokio::test]
779    async fn ssrf_broadcast_blocked() {
780        let err = Http::get("http://255.255.255.255/")
781            .run()
782            .await
783            .unwrap_err();
784        assert!(err.to_string().contains("blocked IP address"));
785    }
786
787    #[tokio::test]
788    async fn ssrf_localhost_with_port_blocked() {
789        let err = Http::get("http://127.0.0.1:8080/secret")
790            .run()
791            .await
792            .unwrap_err();
793        assert!(err.to_string().contains("blocked IP address"));
794    }
795
796    #[test]
797    fn url_trimming_stores_trimmed() {
798        let http = Http::get("  https://example.com  ");
799        assert_eq!(http.url, "https://example.com");
800    }
801
802    #[test]
803    fn text_body_builder() {
804        let http = Http::post("https://x.com").text("hello body");
805        assert!(matches!(http.body, Some(HttpBody::Text(ref s)) if s == "hello body"));
806    }
807
808    #[test]
809    fn json_body_builder_stores_value() {
810        let http = Http::post("https://x.com").json(serde_json::json!({"k": "v"}));
811        assert!(matches!(http.body, Some(HttpBody::Json(_))));
812    }
813
814    #[test]
815    fn max_response_size_builder() {
816        let http = Http::get("https://x.com").max_response_size(1024);
817        assert_eq!(http.max_response_size, 1024);
818    }
819
820    #[test]
821    fn dry_run_builder_stores_flag() {
822        let http = Http::get("https://x.com").dry_run(true);
823        assert_eq!(http.dry_run, Some(true));
824    }
825
826    #[test]
827    fn retry_builder_stores_policy() {
828        let http = Http::get("https://x.com").retry(3);
829        assert!(http.retry_policy.is_some());
830        assert_eq!(http.retry_policy.unwrap().max_retries(), 3);
831    }
832
833    #[test]
834    fn retry_policy_builder_stores_custom_policy() {
835        let policy = RetryPolicy::new(5)
836            .backoff(Duration::from_secs(1))
837            .multiplier(3.0);
838        let http = Http::get("https://x.com").retry_policy(policy);
839        let p = http.retry_policy.unwrap();
840        assert_eq!(p.max_retries(), 5);
841        assert_eq!(p.initial_backoff, Duration::from_secs(1));
842    }
843
844    #[test]
845    fn no_retry_by_default() {
846        let http = Http::get("https://x.com");
847        assert!(http.retry_policy.is_none());
848    }
849
850    #[test]
851    fn http_output_accessors() {
852        let mut headers = HashMap::new();
853        headers.insert("content-type".to_string(), "text/plain".to_string());
854        let output = HttpOutput {
855            status: 201,
856            headers,
857            body: "hello".to_string(),
858            duration_ms: 42,
859        };
860        assert_eq!(output.status(), 201);
861        assert_eq!(output.body(), "hello");
862        assert_eq!(output.duration_ms(), 42);
863        assert_eq!(output.headers().get("content-type").unwrap(), "text/plain");
864    }
865
866    #[tokio::test]
867    async fn ssrf_userinfo_in_url_blocked() {
868        let err = Http::get("http://user:pass@127.0.0.1/secret")
869            .run()
870            .await
871            .unwrap_err();
872        assert!(err.to_string().contains("blocked IP address"));
873    }
874
875    #[test]
876    fn check_url_host_with_userinfo_detects_blocked_ip() {
877        let result = check_url_host("http://admin:secret@10.0.0.1/path");
878        assert!(result.is_some());
879        assert!(result.unwrap().contains("blocked IP address"));
880    }
881
882    #[test]
883    fn check_url_host_public_ip_with_userinfo_allowed() {
884        let result = check_url_host("http://user:pass@8.8.8.8/dns");
885        assert!(result.is_none());
886    }
887
888    #[test]
889    fn redirect_policy_is_none() {
890        let client = &*HTTP_CLIENT;
891        let _ = client;
892    }
893
894    #[tokio::test]
895    async fn no_redirect_returns_3xx_status() {
896        use tokio::net::TcpListener;
897
898        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
899        let port = listener.local_addr().unwrap().port();
900
901        let server = tokio::spawn(async move {
902            let (mut socket, _) = listener.accept().await.unwrap();
903            use tokio::io::AsyncWriteExt;
904            let response =
905                "HTTP/1.1 302 Found\r\nLocation: http://10.0.0.1/evil\r\nContent-Length: 0\r\n\r\n";
906            socket.write_all(response.as_bytes()).await.unwrap();
907            socket.shutdown().await.unwrap();
908        });
909
910        let url = format!("http://localhost:{port}/test");
911
912        let output = Http::get(&url)
913            .timeout(Duration::from_secs(5))
914            .run()
915            .await
916            .unwrap();
917
918        assert_eq!(output.status(), 302);
919
920        server.await.unwrap();
921    }
922
923    #[tokio::test]
924    async fn streaming_body_size_check_aborts_over_limit() {
925        use tokio::net::TcpListener;
926
927        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
928        let port = listener.local_addr().unwrap().port();
929
930        let server = tokio::spawn(async move {
931            let (mut socket, _) = listener.accept().await.unwrap();
932            use tokio::io::AsyncWriteExt;
933            let body = "x".repeat(2048);
934            let response = format!(
935                "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n{}\r\n0\r\n\r\n",
936                body.len(),
937                body,
938            );
939            socket.write_all(response.as_bytes()).await.unwrap();
940            socket.shutdown().await.unwrap();
941        });
942
943        let url = format!("http://localhost:{port}/big");
944
945        let result = Http::new(Method::GET, &url)
946            .max_response_size(1024)
947            .timeout(Duration::from_secs(5))
948            .run()
949            .await;
950
951        assert!(result.is_err());
952        let err = result.unwrap_err();
953        assert!(err.to_string().contains("response body too large"));
954
955        server.await.unwrap();
956    }
957}