Skip to main content

feedparser_rs/http/
client.rs

1use super::resolver::SsrfSafeResolver;
2use super::response::FeedHttpResponse;
3use super::validation::validate_url;
4use crate::error::{FeedError, Result};
5use reqwest::blocking::{Client, Response};
6use reqwest::header::{
7    ACCEPT, ACCEPT_ENCODING, HeaderMap, HeaderName, HeaderValue, IF_MODIFIED_SINCE, IF_NONE_MATCH,
8    USER_AGENT,
9};
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::Duration;
13
14/// Maximum number of redirects to follow before aborting, matching the hop
15/// count `reqwest::redirect::Policy::limited` used to enforce. A custom
16/// policy does not get this for free, so [`FeedHttpClient::redirect_policy`]
17/// enforces it explicitly alongside SSRF re-validation.
18const MAX_REDIRECTS: usize = 10;
19
20/// Default per-request timeout used both to build the underlying
21/// `reqwest::blocking::Client` in [`FeedHttpClient::new`] and to seed
22/// [`FeedHttpClient::timeout`], so the two never drift apart.
23const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
24
25/// HTTP client for fetching feeds
26pub struct FeedHttpClient {
27    client: Client,
28    user_agent: String,
29    timeout: Duration,
30}
31
32impl FeedHttpClient {
33    /// Creates a new HTTP client with default settings
34    ///
35    /// Default settings:
36    /// - 30 second timeout
37    /// - Gzip, deflate, and brotli compression enabled
38    /// - Maximum 10 redirects, each re-validated against the SSRF checks
39    /// - DNS resolution re-validated against the SSRF checks to close
40    ///   DNS-rebinding gaps between validation and connect time
41    /// - No system/environment proxy (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`)
42    ///   is honored: a proxy connects through a hostname that is resolved by
43    ///   the proxy itself, not by the DNS-rebinding-safe resolver above,
44    ///   which would silently defeat that protection
45    /// - Custom User-Agent
46    ///
47    /// # Errors
48    ///
49    /// Returns `FeedError::Http` if the underlying HTTP client cannot be created.
50    pub fn new() -> Result<Self> {
51        let client = Client::builder()
52            .timeout(DEFAULT_TIMEOUT)
53            .gzip(true)
54            .deflate(true)
55            .brotli(true)
56            .redirect(Self::redirect_policy())
57            .dns_resolver(Arc::new(SsrfSafeResolver))
58            .no_proxy()
59            .build()
60            .map_err(|e| FeedError::Http {
61                message: format!("Failed to create HTTP client: {e}"),
62            })?;
63
64        Ok(Self {
65            client,
66            user_agent: format!(
67                "feedparser-rs/{} (+https://github.com/bug-ops/feedparser-rs)",
68                env!("CARGO_PKG_VERSION")
69            ),
70            timeout: DEFAULT_TIMEOUT,
71        })
72    }
73
74    /// Decides whether a redirect hop should be followed.
75    ///
76    /// Extracted from [`Self::redirect_policy`] so the decision can be unit
77    /// tested directly: `reqwest::redirect::Attempt` has no public
78    /// constructor, so the policy closure itself cannot be exercised without
79    /// a live HTTP round trip.
80    ///
81    /// # Errors
82    ///
83    /// Returns `FeedError::Http` if the hop count exceeds `MAX_REDIRECTS`
84    /// or `next_url` fails SSRF validation.
85    fn should_follow_redirect(next_url: &str, previous_hops: usize) -> Result<()> {
86        if previous_hops > MAX_REDIRECTS {
87            return Err(FeedError::Http {
88                message: format!("Too many redirects (max {MAX_REDIRECTS})"),
89            });
90        }
91
92        validate_url(next_url)?;
93        Ok(())
94    }
95
96    /// Formats a `reqwest::Error` including its full `source()` chain.
97    ///
98    /// `reqwest::Error`'s `Display` only prints the outer error kind and URL —
99    /// it never walks the source chain. Without this, the SSRF rejection
100    /// reason attached via `redirect::Attempt::error` (see
101    /// [`Self::redirect_policy`]) or a resolver failure (see
102    /// `resolver::SsrfSafeResolver`) is silently dropped, and callers only
103    /// see a generic "error following redirect" / "error sending request".
104    fn describe_request_error(error: &reqwest::Error) -> String {
105        use std::fmt::Write as _;
106
107        let mut message = error.to_string();
108        let mut source = std::error::Error::source(error);
109        while let Some(err) = source {
110            let _ = write!(message, ": {err}");
111            source = err.source();
112        }
113        message
114    }
115
116    /// Builds a redirect policy that re-validates every hop against the SSRF
117    /// checks in [`validate_url`], not just the initial request URL.
118    ///
119    /// `reqwest`'s built-in `Policy::limited` only counts hops — it never
120    /// re-runs SSRF validation on the `Location` header, so a malicious
121    /// server could pass the initial check and then redirect to an internal
122    /// address (e.g. cloud metadata endpoints) and have it followed
123    /// silently. A custom policy also does not enforce a hop limit on its
124    /// own, so [`Self::should_follow_redirect`] replicates `Policy::limited`'s
125    /// bound explicitly.
126    fn redirect_policy() -> reqwest::redirect::Policy {
127        reqwest::redirect::Policy::custom(|attempt| {
128            match Self::should_follow_redirect(attempt.url().as_str(), attempt.previous().len()) {
129                Ok(()) => attempt.follow(),
130                Err(e) => attempt.error(e),
131            }
132        })
133    }
134
135    /// Sets a custom User-Agent header
136    ///
137    /// # Security
138    ///
139    /// User-Agent is truncated to 512 bytes to prevent header injection attacks.
140    #[must_use]
141    pub fn with_user_agent(mut self, agent: String) -> Self {
142        // Truncate to 512 bytes to prevent header injection
143        const MAX_USER_AGENT_LEN: usize = 512;
144        self.user_agent = if agent.len() > MAX_USER_AGENT_LEN {
145            agent.chars().take(MAX_USER_AGENT_LEN).collect()
146        } else {
147            agent
148        };
149        self
150    }
151
152    /// Sets the per-request timeout applied to every [`Self::get`] call.
153    ///
154    /// The value is a total deadline covering connection, all redirect hops,
155    /// and the full response body — not a per-hop timeout. `Duration::ZERO`
156    /// means "time out immediately", not "disable the timeout"; this API has
157    /// no way to disable it entirely. Values above one hour are clamped to
158    /// one hour: `reqwest`'s blocking wait computes its deadline as
159    /// `Instant::now() + timeout` without an overflow check, so passing
160    /// `Duration::MAX` (or another value near the `Instant` range limit)
161    /// would otherwise panic instead of erroring.
162    #[must_use]
163    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
164        const MAX_TIMEOUT_SECS: u64 = 3600;
165        self.timeout = if timeout.as_secs() > MAX_TIMEOUT_SECS {
166            Duration::from_secs(MAX_TIMEOUT_SECS)
167        } else {
168            timeout
169        };
170        self
171    }
172
173    /// Insert header with consistent error handling
174    ///
175    /// Helper method to reduce boilerplate in header insertion.
176    #[inline]
177    fn insert_header(
178        headers: &mut HeaderMap,
179        name: HeaderName,
180        value: &str,
181        field_name: &str,
182    ) -> Result<()> {
183        headers.insert(
184            name,
185            HeaderValue::from_str(value).map_err(|e| FeedError::Http {
186                message: format!("Invalid {field_name}: {e}"),
187            })?,
188        );
189        Ok(())
190    }
191
192    /// Fetches a feed from the given URL
193    ///
194    /// Supports conditional GET with `ETag` and `Last-Modified` headers.
195    ///
196    /// # Arguments
197    ///
198    /// * `url` - HTTP/HTTPS URL to fetch
199    /// * `etag` - Optional `ETag` from previous fetch
200    /// * `modified` - Optional `Last-Modified` from previous fetch
201    /// * `extra_headers` - Additional custom headers
202    ///
203    /// # Errors
204    ///
205    /// Returns `FeedError::Http` if the request fails or headers are invalid.
206    pub fn get(
207        &self,
208        url: &str,
209        etag: Option<&str>,
210        modified: Option<&str>,
211        extra_headers: Option<&HeaderMap>,
212    ) -> Result<FeedHttpResponse> {
213        // Validate URL to prevent SSRF attacks
214        let validated_url = validate_url(url)?;
215        let url_str = validated_url.as_str();
216
217        let mut headers = HeaderMap::new();
218
219        // Standard headers
220        Self::insert_header(&mut headers, USER_AGENT, &self.user_agent, "User-Agent")?;
221
222        headers.insert(
223            ACCEPT,
224            HeaderValue::from_static(
225                "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
226            ),
227        );
228
229        headers.insert(
230            ACCEPT_ENCODING,
231            HeaderValue::from_static("gzip, deflate, br"),
232        );
233
234        // Conditional GET headers with length validation
235        if let Some(etag_val) = etag {
236            // Truncate ETag to 1KB to prevent oversized headers
237            const MAX_ETAG_LEN: usize = 1024;
238            let sanitized_etag = if etag_val.len() > MAX_ETAG_LEN {
239                &etag_val[..MAX_ETAG_LEN]
240            } else {
241                etag_val
242            };
243            Self::insert_header(&mut headers, IF_NONE_MATCH, sanitized_etag, "ETag")?;
244        }
245
246        if let Some(modified_val) = modified {
247            // Truncate Last-Modified to 64 bytes (RFC 822 dates are ~30 bytes)
248            const MAX_MODIFIED_LEN: usize = 64;
249            let sanitized_modified = if modified_val.len() > MAX_MODIFIED_LEN {
250                &modified_val[..MAX_MODIFIED_LEN]
251            } else {
252                modified_val
253            };
254            Self::insert_header(
255                &mut headers,
256                IF_MODIFIED_SINCE,
257                sanitized_modified,
258                "Last-Modified",
259            )?;
260        }
261
262        // Merge extra headers
263        if let Some(extra) = extra_headers {
264            headers.extend(extra.clone());
265        }
266
267        let request = self.build_request(url_str, headers)?;
268
269        let response = self.client.execute(request).map_err(|e| FeedError::Http {
270            message: format!("HTTP request failed: {}", Self::describe_request_error(&e)),
271        })?;
272
273        Self::build_response(response, url_str)
274    }
275
276    /// Builds the GET request for `url_str` with `headers`, applying the
277    /// configured per-request timeout.
278    ///
279    /// Extracted from [`Self::get`] so the built [`reqwest::blocking::Request`]
280    /// can be inspected directly in tests (via [`reqwest::blocking::Request::timeout`])
281    /// without a network round trip.
282    ///
283    /// # Errors
284    ///
285    /// Returns `FeedError::Http` if the request cannot be constructed.
286    fn build_request(
287        &self,
288        url_str: &str,
289        headers: HeaderMap,
290    ) -> Result<reqwest::blocking::Request> {
291        self.client
292            .get(url_str)
293            .headers(headers)
294            .timeout(self.timeout)
295            .build()
296            .map_err(|e| FeedError::Http {
297                message: format!(
298                    "Failed to build request: {}",
299                    Self::describe_request_error(&e)
300                ),
301            })
302    }
303
304    /// Converts `reqwest` Response to `FeedHttpResponse`
305    fn build_response(response: Response, _original_url: &str) -> Result<FeedHttpResponse> {
306        let status = response.status().as_u16();
307        let url = response.url().to_string();
308
309        // Convert headers to HashMap with pre-allocated capacity
310        let mut headers_map = HashMap::with_capacity(response.headers().len());
311        for (name, value) in response.headers() {
312            if let Ok(val_str) = value.to_str() {
313                headers_map.insert(name.to_string(), val_str.to_string());
314            }
315        }
316
317        // Extract caching headers
318        let etag = headers_map.get("etag").cloned();
319        let last_modified = headers_map.get("last-modified").cloned();
320        let content_type = headers_map.get("content-type").cloned();
321
322        // Extract encoding from Content-Type
323        let encoding = content_type
324            .as_ref()
325            .and_then(|ct| FeedHttpResponse::extract_charset_from_content_type(ct));
326
327        // Read body (handles gzip/deflate automatically)
328        let body = if status == 304 {
329            // Not Modified - no body
330            Vec::new()
331        } else {
332            response
333                .bytes()
334                .map_err(|e| FeedError::Http {
335                    message: format!("Failed to read response body: {e}"),
336                })?
337                .to_vec()
338        };
339
340        Ok(FeedHttpResponse {
341            status,
342            url,
343            headers: headers_map,
344            body,
345            etag,
346            last_modified,
347            content_type,
348            encoding,
349        })
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_client_creation() {
359        let client = FeedHttpClient::new();
360        assert!(client.is_ok());
361    }
362
363    // === Redirect re-validation tests ===
364
365    #[test]
366    fn test_redirect_rejects_metadata_endpoint() {
367        let result =
368            FeedHttpClient::should_follow_redirect("http://169.254.169.254/latest/meta-data/", 1);
369        assert!(result.is_err());
370    }
371
372    #[test]
373    fn test_redirect_rejects_private_ip() {
374        let result = FeedHttpClient::should_follow_redirect("http://10.0.0.5/admin", 1);
375        assert!(result.is_err());
376    }
377
378    #[test]
379    fn test_redirect_allows_public_ip_within_hop_limit() {
380        let result = FeedHttpClient::should_follow_redirect("http://8.8.8.8/", 1);
381        assert!(result.is_ok());
382    }
383
384    #[test]
385    fn test_redirect_rejects_over_hop_limit_even_for_safe_url() {
386        let result = FeedHttpClient::should_follow_redirect("http://8.8.8.8/", MAX_REDIRECTS + 1);
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_redirect_allows_at_hop_limit() {
392        let result = FeedHttpClient::should_follow_redirect("http://8.8.8.8/", MAX_REDIRECTS);
393        assert!(result.is_ok());
394    }
395
396    #[test]
397    #[allow(clippy::significant_drop_tightening)]
398    fn test_redirect_to_metadata_endpoint_rejected_end_to_end() {
399        let mut server = mockito::Server::new();
400        let mock = server
401            .mock("GET", "/redirect")
402            .with_status(302)
403            .with_header("location", "http://169.254.169.254/latest/meta-data/")
404            .create();
405
406        // Bypasses the front-door `validate_url` (which would reject the
407        // mockito server's loopback address) to exercise only the redirect
408        // policy against a real HTTP redirect chain.
409        let client = Client::builder()
410            .redirect(FeedHttpClient::redirect_policy())
411            .build()
412            .unwrap();
413
414        let url = format!("{}/redirect", server.url());
415        let result = client.get(&url).send();
416
417        let err = result.expect_err("redirect to a metadata IP must be rejected");
418        // A broken policy would also error here (169.254.169.254 is
419        // unroutable from the test host), so assert on the actual SSRF
420        // rejection reason surviving through `describe_request_error`, not
421        // just on any error — proving the *redirect policy itself* is what
422        // rejected it, not a downstream connection failure.
423        let description = FeedHttpClient::describe_request_error(&err);
424        assert!(
425            description.contains("Link-local address not allowed"),
426            "expected the SSRF rejection reason in the error chain, got: {description}"
427        );
428        mock.assert();
429    }
430
431    #[test]
432    #[allow(clippy::significant_drop_tightening)]
433    fn test_redirect_follows_legitimate_multi_hop_chain() {
434        let mut server = mockito::Server::new();
435        let addr = server.socket_address();
436
437        let mock1 = server
438            .mock("GET", "/hop1")
439            .with_status(302)
440            .with_header("location", "http://public.test/hop2")
441            .create();
442        let mock2 = server
443            .mock("GET", "/hop2")
444            .with_status(302)
445            .with_header("location", "http://public.test/hop3")
446            .create();
447        let mock3 = server
448            .mock("GET", "/hop3")
449            .with_status(200)
450            .with_body("ok")
451            .create();
452
453        // `.resolve()` points the SSRF-passing domain "public.test" at the
454        // mock server's real (loopback) address, so the full policy +
455        // resolver stack can be driven end-to-end offline without the
456        // front door or `SsrfSafeResolver` rejecting the mock's actual
457        // loopback IP — proving legitimate multi-hop chains still work.
458        let client = Client::builder()
459            .redirect(FeedHttpClient::redirect_policy())
460            .dns_resolver(Arc::new(SsrfSafeResolver))
461            .resolve("public.test", addr)
462            .build()
463            .unwrap();
464
465        let url = format!("http://public.test:{}/hop1", addr.port());
466        let response = client.get(&url).send().unwrap();
467
468        assert_eq!(response.status().as_u16(), 200);
469        mock1.assert();
470        mock2.assert();
471        mock3.assert();
472    }
473
474    #[test]
475    #[allow(clippy::significant_drop_tightening)]
476    fn test_redirect_chain_rejects_metadata_after_legitimate_hops() {
477        let mut server = mockito::Server::new();
478        let addr = server.socket_address();
479
480        let mock1 = server
481            .mock("GET", "/hop1")
482            .with_status(302)
483            .with_header("location", "http://public.test/hop2")
484            .create();
485        let mock2 = server
486            .mock("GET", "/hop2")
487            .with_status(302)
488            .with_header("location", "http://169.254.169.254/latest/meta-data/")
489            .create();
490
491        let client = Client::builder()
492            .redirect(FeedHttpClient::redirect_policy())
493            .dns_resolver(Arc::new(SsrfSafeResolver))
494            .resolve("public.test", addr)
495            .build()
496            .unwrap();
497
498        // This is issue #436's actual attack shape: an initial URL and its
499        // first redirect both look legitimate, only the final hop targets
500        // a cloud metadata address.
501        let url = format!("http://public.test:{}/hop1", addr.port());
502        let err = client.get(&url).send().expect_err(
503            "a chain ending at a metadata IP must be rejected, even after legitimate hops",
504        );
505
506        let description = FeedHttpClient::describe_request_error(&err);
507        assert!(
508            description.contains("Link-local address not allowed"),
509            "expected the SSRF rejection reason to survive a multi-hop chain, got: {description}"
510        );
511        mock1.assert();
512        mock2.assert();
513    }
514
515    #[test]
516    fn test_dns_resolver_wired_into_client_rejects_loopback() {
517        let client = Client::builder()
518            .dns_resolver(Arc::new(SsrfSafeResolver))
519            .build()
520            .unwrap();
521
522        // Exercises the resolver as actually attached to a `Client` (not
523        // just `SsrfSafeResolver::resolve` in isolation): "localhost"
524        // resolves via the OS hosts file to 127.0.0.1/::1, both filtered
525        // out, so the connection must fail before any bytes are sent.
526        let result = client.get("http://localhost/").send();
527        assert!(result.is_err());
528    }
529
530    #[test]
531    fn test_custom_user_agent() {
532        let client = FeedHttpClient::new()
533            .unwrap()
534            .with_user_agent("CustomBot/1.0".to_string());
535        assert_eq!(client.user_agent, "CustomBot/1.0");
536    }
537
538    #[test]
539    fn test_custom_timeout() {
540        let timeout = Duration::from_secs(60);
541        let client = FeedHttpClient::new().unwrap().with_timeout(timeout);
542        assert_eq!(client.timeout, timeout);
543    }
544
545    #[test]
546    fn test_with_timeout_clamps_absurd_duration() {
547        // `Duration::MAX` would overflow `Instant::now() + timeout` inside
548        // reqwest's blocking wait and panic; it must be clamped instead.
549        let client = FeedHttpClient::new().unwrap().with_timeout(Duration::MAX);
550        assert_eq!(client.timeout, Duration::from_secs(3600));
551    }
552
553    #[test]
554    fn test_build_request_applies_configured_timeout() {
555        // Deterministic, network-free complement to the end-to-end tests
556        // below: asserts the timeout is actually wired onto the built
557        // request, not just stored on the struct.
558        let timeout = Duration::from_secs(7);
559        let client = FeedHttpClient::new().unwrap().with_timeout(timeout);
560        let request = client
561            .build_request("http://example.test/feed.xml", HeaderMap::new())
562            .unwrap();
563        assert_eq!(request.timeout(), Some(&timeout));
564    }
565
566    // === Timeout enforcement tests (regression for #451) ===
567
568    #[test]
569    #[allow(clippy::significant_drop_tightening)]
570    fn test_with_timeout_enforced_on_slow_response() {
571        let mut server = mockito::Server::new();
572        let addr = server.socket_address();
573
574        let mock = server
575            .mock("GET", "/slow")
576            .with_chunked_body(|w| {
577                std::thread::sleep(Duration::from_millis(500));
578                w.write_all(b"too slow")
579            })
580            .create();
581
582        // Uses the "public.test" + `.resolve()` trick from the redirect
583        // tests above to drive `FeedHttpClient::get` end-to-end (including
584        // `validate_url`, which would reject the mock server's real
585        // loopback address) while actually connecting to the mock.
586        let raw_client = Client::builder()
587            .dns_resolver(Arc::new(SsrfSafeResolver))
588            .resolve("public.test", addr)
589            .build()
590            .unwrap();
591
592        let client = FeedHttpClient {
593            client: raw_client,
594            user_agent: "test-agent".to_string(),
595            timeout: Duration::from_millis(100),
596        };
597
598        let url = format!("http://public.test:{}/slow", addr.port());
599        let started = std::time::Instant::now();
600        client
601            .get(&url, None, None, None)
602            .expect_err("a response slower than the configured 100ms timeout must fail");
603        let elapsed = started.elapsed();
604
605        // The mock sleeps 500ms before writing its body. Without the fix,
606        // `self.timeout` is never passed to the request builder, so this
607        // request would block for the full 500ms and succeed. Failing well
608        // before that proves `with_timeout` is actually enforced on `get`.
609        assert!(
610            elapsed < Duration::from_millis(400),
611            "request did not fail until {elapsed:?}; timeout was not enforced"
612        );
613        mock.assert();
614    }
615
616    #[test]
617    #[allow(clippy::significant_drop_tightening)]
618    fn test_with_timeout_allows_fast_response() {
619        let mut server = mockito::Server::new();
620        let addr = server.socket_address();
621
622        let mock = server
623            .mock("GET", "/fast")
624            .with_status(200)
625            .with_body("ok")
626            .create();
627
628        let raw_client = Client::builder()
629            .dns_resolver(Arc::new(SsrfSafeResolver))
630            .resolve("public.test", addr)
631            .build()
632            .unwrap();
633
634        let client = FeedHttpClient {
635            client: raw_client,
636            user_agent: "test-agent".to_string(),
637            timeout: Duration::from_secs(5),
638        };
639
640        let url = format!("http://public.test:{}/fast", addr.port());
641        let response = client
642            .get(&url, None, None, None)
643            .expect("a response faster than the configured timeout must succeed");
644
645        assert_eq!(response.status, 200);
646        mock.assert();
647    }
648
649    // SSRF protection tests
650    #[test]
651    fn test_reject_localhost_url() {
652        let client = FeedHttpClient::new().unwrap();
653        let result = client.get("http://localhost/feed.xml", None, None, None);
654        assert!(result.is_err());
655        let err_msg = result.err().unwrap().to_string();
656        assert!(err_msg.contains("Localhost domain not allowed"));
657    }
658
659    #[test]
660    fn test_reject_private_ip() {
661        let client = FeedHttpClient::new().unwrap();
662        let result = client.get("http://192.168.1.1/feed.xml", None, None, None);
663        assert!(result.is_err());
664        let err_msg = result.err().unwrap().to_string();
665        assert!(err_msg.contains("Private IP address not allowed"));
666    }
667
668    #[test]
669    fn test_reject_metadata_endpoint() {
670        let client = FeedHttpClient::new().unwrap();
671        let result = client.get("http://169.254.169.254/latest/meta-data/", None, None, None);
672        assert!(result.is_err());
673        let err_msg = result.err().unwrap().to_string();
674        // Should be rejected as AWS metadata endpoint or link-local
675        assert!(err_msg.contains("metadata") || err_msg.contains("Link-local"));
676    }
677
678    #[test]
679    fn test_reject_file_scheme() {
680        let client = FeedHttpClient::new().unwrap();
681        let result = client.get("file:///etc/passwd", None, None, None);
682        assert!(result.is_err());
683        let err_msg = result.err().unwrap().to_string();
684        assert!(err_msg.contains("Unsupported URL scheme"));
685    }
686
687    #[test]
688    fn test_reject_internal_domain() {
689        let client = FeedHttpClient::new().unwrap();
690        let result = client.get("http://server.local/feed.xml", None, None, None);
691        assert!(result.is_err());
692        let err_msg = result.err().unwrap().to_string();
693        assert!(err_msg.contains("Internal domain TLD not allowed"));
694    }
695
696    #[test]
697    fn test_insert_header_valid() {
698        let mut headers = HeaderMap::new();
699        let result =
700            FeedHttpClient::insert_header(&mut headers, USER_AGENT, "TestBot/1.0", "User-Agent");
701        assert!(result.is_ok());
702        assert_eq!(headers.get(USER_AGENT).unwrap(), "TestBot/1.0");
703    }
704
705    #[test]
706    fn test_insert_header_invalid_value() {
707        let mut headers = HeaderMap::new();
708        // Invalid header value with control characters
709        let result = FeedHttpClient::insert_header(
710            &mut headers,
711            USER_AGENT,
712            "Invalid\nHeader",
713            "User-Agent",
714        );
715        assert!(result.is_err());
716        match result {
717            Err(FeedError::Http { message }) => {
718                assert!(message.contains("Invalid User-Agent"));
719            }
720            _ => panic!("Expected Http error"),
721        }
722    }
723
724    #[test]
725    fn test_insert_header_multiple_headers() {
726        let mut headers = HeaderMap::new();
727
728        FeedHttpClient::insert_header(&mut headers, USER_AGENT, "TestBot/1.0", "User-Agent")
729            .unwrap();
730
731        FeedHttpClient::insert_header(&mut headers, ACCEPT, "application/xml", "Accept").unwrap();
732
733        assert_eq!(headers.len(), 2);
734        assert_eq!(headers.get(USER_AGENT).unwrap(), "TestBot/1.0");
735        assert_eq!(headers.get(ACCEPT).unwrap(), "application/xml");
736    }
737}