Skip to main content

thndrs_lib/core/
search.rs

1//! Application-owned web search and article extraction.
2//!
3//! Search backends are deliberately kept outside provider adapters. DuckDuckGo
4//! is the default public backend; an explicitly configured SearXNG instance is
5//! also supported, while `none` disables this boundary. Search results are
6//! normalized before their public pages are fetched through the same bounded
7//! Lectito-backed extraction path used by `read_url`.
8//!
9//! - Search: sync HTTP via [`ureq`] against `html.duckduckgo.com/html/`.
10//! - Parsing: ported from lectito's mcp bin, using [`scraper`] for CSS
11//!   selectors.
12//! - Extraction: [`lectito::extract`] for HTML → Markdown/text.
13//! - Bot-challenge detection: checks for known DDG anomaly markers.
14//! - Result limits: kept small (default 5, hard maximum 10).
15
16use std::io;
17use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
18use std::time::{Duration, Instant};
19
20use crate::cli::WebSearchMode;
21use scraper::{Html, Selector};
22use ureq::unversioned::resolver::{DefaultResolver, ResolvedSocketAddrs, Resolver};
23use ureq::unversioned::transport::{DefaultConnector, NextTimeout};
24
25/// Maximum number of local search results returned by default.
26pub const DEFAULT_SEARCH_LIMIT: usize = 5;
27/// Hard maximum number of search results returned by one tool call.
28pub const MAX_SEARCH_LIMIT: usize = 10;
29
30/// DuckDuckGo's form-backed HTML search endpoint.
31pub const DUCKDUCKGO_HTML_URL: &str = "https://html.duckduckgo.com/html/";
32
33/// Maximum article content length before truncation.
34const MAX_ARTICLE_CONTENT_LEN: usize = 65_536;
35
36/// Maximum response body size for fetched URLs (1 MiB).
37const MAX_RESPONSE_BYTES: usize = 1_048_576;
38
39/// Maximum response body size accepted from a search backend.
40const MAX_SEARCH_RESPONSE_BYTES: usize = 512 * 1024;
41
42/// Maximum combined output emitted by one `web_search` call.
43pub const MAX_SEARCH_OUTPUT_BYTES: usize = 65_536;
44
45/// Maximum number of HTTP redirects to follow. ureq's default is 10; we tighten
46/// this to keep redirect chains short and bounded.
47const MAX_REDIRECTS: u32 = 5;
48
49/// Hard timeout (seconds) for the entire `read_url` fetch: DNS, connect, TLS,
50/// redirects, and body read. Prevents a slow or malicious server from hanging
51/// the agent loop.
52const FETCH_TIMEOUT_SECS: u64 = 15;
53
54/// Marker used to distinguish a resolver-level public-network rejection from
55/// unrelated I/O permission failures.
56const PRIVATE_RESOLUTION_ERROR: &str = "resolved host includes a non-public network address";
57
58/// User agent string for DuckDuckGo requests.
59const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)  \
60     AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36";
61
62type Result<T> = std::result::Result<T, SearchError>;
63
64/// Errors from local search or extraction.
65#[derive(Debug, thiserror::Error)]
66pub enum SearchError {
67    /// HTTP transport error.
68    #[error("http error: {0}")]
69    Http(String),
70    /// Non-success HTTP status.
71    #[error("http {status}: {body}")]
72    HttpStatus { status: u16, body: String },
73    /// DuckDuckGo returned an anti-bot page instead of search results.
74    #[error("bot challenge: {0}")]
75    Blocked(String),
76    /// A search backend returned malformed JSON.
77    #[error("invalid search JSON: {0}")]
78    Json(String),
79    /// The configured search backend is incomplete or invalid.
80    #[error("invalid search configuration: {0}")]
81    InvalidConfiguration(String),
82    /// The application-owned web search backend is disabled.
83    #[error("web search is disabled")]
84    Disabled,
85    /// Lectito extraction failed.
86    #[error("extraction error: {0}")]
87    Extraction(String),
88    /// A hard-coded selector failed to parse.
89    #[error("invalid CSS selector: {0}")]
90    InvalidSelector(&'static str),
91    /// The URL scheme is not `http` or `https`.
92    #[error("unsupported URL scheme: {0}")]
93    UnsupportedScheme(String),
94    /// The URL points to a private/loopback network address.
95    #[error("private network target rejected: {0}")]
96    PrivateNetwork(String),
97    /// The redirect chain exceeded the configured limit.
98    #[error("too many redirects (max {max})")]
99    TooManyRedirects { max: u32 },
100    /// The request did not complete within the timeout.
101    #[error("request timed out after {secs}s")]
102    Timeout { secs: u64 },
103    /// The response exceeded the maximum allowed size.
104    #[error("response too large (>{max} bytes)")]
105    Oversized { max: usize },
106    /// The response content type is not HTML.
107    #[error("unexpected content type: {0}")]
108    BadContentType(String),
109}
110
111/// Resolver wrapper that validates the exact addresses handed to the socket
112/// connector. Rejecting the whole answer when any address is non-public avoids
113/// DNS rebinding and mixed public/private answer ambiguity.
114#[derive(Debug)]
115struct PublicResolver<R> {
116    inner: R,
117}
118
119impl<R> PublicResolver<R> {
120    fn new(inner: R) -> Self {
121        Self { inner }
122    }
123}
124
125impl<R: Resolver> Resolver for PublicResolver<R> {
126    fn resolve(
127        &self, uri: &ureq::http::Uri, config: &ureq::config::Config, timeout: NextTimeout,
128    ) -> std::result::Result<ResolvedSocketAddrs, ureq::Error> {
129        let addresses = self.inner.resolve(uri, config, timeout)?;
130        if addresses.iter().any(|address| !is_public_ip(address.ip())) {
131            return Err(ureq::Error::Io(io::Error::new(
132                io::ErrorKind::PermissionDenied,
133                PRIVATE_RESOLUTION_ERROR,
134            )));
135        }
136        Ok(addresses)
137    }
138}
139
140/// One result from DuckDuckGo's HTML search page.
141#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct SearchResult {
143    /// The result title as displayed by DuckDuckGo.
144    pub title: String,
145    /// The normalized target URL.
146    pub url: String,
147    /// DuckDuckGo's result snippet, when present.
148    pub snippet: Option<String>,
149}
150
151/// Configuration consumed by the application-owned search tool.
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct SearchConfig {
154    /// Selected application-owned backend.
155    pub backend: WebSearchMode,
156    /// Base URL for SearXNG, when that backend is selected.
157    pub searxng_url: Option<String>,
158}
159
160impl Default for SearchConfig {
161    fn default() -> Self {
162        Self { backend: WebSearchMode::DuckDuckGo, searxng_url: None }
163    }
164}
165
166impl SearchConfig {
167    /// Construct and validate a backend configuration.
168    pub fn new(backend: WebSearchMode, searxng_url: Option<String>) -> Result<Self> {
169        let config = Self { backend, searxng_url: searxng_url.map(|url| url.trim().to_string()) };
170        config.validate()?;
171        Ok(config)
172    }
173
174    /// Construct a configuration from already merged application settings.
175    /// Runtime validation remains at the tool boundary so malformed CLI-only
176    /// combinations still become structured tool errors.
177    pub fn from_parts(backend: WebSearchMode, searxng_url: Option<String>) -> Self {
178        Self { backend, searxng_url: searxng_url.map(|url| url.trim().to_string()) }
179    }
180
181    /// Validate the selected backend and any required SearXNG base URL.
182    pub fn validate(&self) -> Result<()> {
183        if self.backend != WebSearchMode::Searxng {
184            return Ok(());
185        }
186
187        let Some(base_url) = self.searxng_url.as_deref().filter(|url| !url.is_empty()) else {
188            return Err(SearchError::InvalidConfiguration(
189                "searxng requires an HTTP(S) base URL".to_string(),
190            ));
191        };
192        validate_search_base_url(base_url)
193    }
194}
195
196/// One normalized result plus the best-effort extracted page content.
197#[derive(Clone, Debug, Eq, PartialEq)]
198pub struct SearchResultContent {
199    /// Normalized search result metadata.
200    pub result: SearchResult,
201    /// Extracted page content when the result URL could be fetched safely.
202    pub content: Option<FetchedContent>,
203    /// Per-result extraction error, retained so later results can succeed.
204    pub error: Option<String>,
205}
206
207/// Extracted article content.
208#[derive(Clone, Debug, PartialEq, Eq)]
209pub struct ArticleContent {
210    /// Article title, if detected.
211    pub title: String,
212    /// Markdown-formatted content.
213    pub markdown: String,
214    /// Plain text content.
215    pub text_content: String,
216    /// Whether the content was truncated to fit the size cap.
217    pub truncated: bool,
218}
219
220/// Content fetched from a public URL.
221#[derive(Clone, Debug, PartialEq, Eq)]
222pub struct FetchedContent {
223    /// The final URL after redirects.
224    pub final_url: String,
225    /// HTTP status code of the final response.
226    pub status: u16,
227    /// The page title (from Lectito extraction, if available).
228    pub title: String,
229    /// Markdown-formatted content.
230    pub markdown: String,
231    /// Plain text content.
232    pub text_content: String,
233    /// Whether the content was truncated.
234    pub truncated: bool,
235    /// Diagnostics: redirects followed, content-type seen, limits applied.
236    pub diagnostics: Vec<String>,
237}
238
239/// Check whether a URL string uses a public scheme (`http` or `https`).
240pub fn is_public_scheme(url_str: &str) -> bool {
241    url_str.starts_with("http://") || url_str.starts_with("https://")
242}
243
244/// Check whether a URL has a literal non-public network address or a localhost
245/// hostname. Domain names are resolved and checked at connection time by the
246/// fetcher's public-address resolver.
247pub fn is_private_url(url_str: &str) -> bool {
248    let Ok(parsed) = url::Url::parse(url_str) else {
249        return true;
250    };
251
252    let scheme = parsed.scheme();
253    if scheme != "http" && scheme != "https" {
254        return true;
255    }
256
257    let host = match parsed.host_str() {
258        Some(h) => h,
259        None => return true,
260    };
261
262    if host.eq_ignore_ascii_case("localhost") || host.eq_ignore_ascii_case("localhost.") {
263        return true;
264    }
265
266    match parsed.host() {
267        Some(url::Host::Ipv4(v4)) => !is_public_ipv4(v4),
268        Some(url::Host::Ipv6(v6)) => !is_public_ipv6(v6),
269        Some(url::Host::Domain(_)) => false,
270        None => true,
271    }
272}
273
274/// Fetch a public URL and extract readable content.
275///
276/// ## Safety guards
277///
278/// - Only `http`/`https` schemes are allowed.
279/// - Literal and DNS-resolved non-public addresses are rejected before a
280///   connection is opened.
281/// - Redirects are followed one at a time and every target is validated before
282///   its request is sent.
283/// - At most `MAX_REDIRECTS` redirects are followed; the chain errors on excess.
284/// - The entire request is bounded by a `FETCH_TIMEOUT_SECS` global timeout.
285/// - Response size is capped at `MAX_RESPONSE_BYTES`, enforced *while streaming*
286///   so a large body cannot exhaust memory before the cap triggers.
287/// - Content type must be on the [`allowed_content_kind`] allow-list: HTML/XHTML
288///   is extracted via Lectito; other text types (JSON, XML, plain text, feeds,
289///   YAML, CSV, JS) are returned as raw text. Binary types are rejected.
290pub fn fetch_url(url_str: &str) -> Result<FetchedContent> {
291    fetch_url_with_agent_factory(url_str, public_fetch_agent)
292}
293
294fn fetch_url_with_agent_factory(
295    url_str: &str, mut agent_factory: impl FnMut(Duration) -> ureq::Agent,
296) -> Result<FetchedContent> {
297    if !is_public_scheme(url_str) {
298        return Err(SearchError::UnsupportedScheme(url_str.to_string()));
299    }
300    if is_private_url(url_str) {
301        return Err(SearchError::PrivateNetwork(url_str.to_string()));
302    }
303
304    let started = Instant::now();
305    let timeout = Duration::from_secs(FETCH_TIMEOUT_SECS);
306    let mut current = url::Url::parse(url_str).map_err(|error| SearchError::Http(error.to_string()))?;
307    let mut redirect_count = 0;
308
309    let response = loop {
310        let remaining = timeout
311            .checked_sub(started.elapsed())
312            .filter(|remaining| !remaining.is_zero())
313            .ok_or(SearchError::Timeout { secs: FETCH_TIMEOUT_SECS })?;
314        let agent = agent_factory(remaining);
315        let response = request_public_url(&agent, current.as_str())?;
316
317        let Some(next) = redirect_target(&current, &response)? else {
318            break response;
319        };
320        if redirect_count >= MAX_REDIRECTS {
321            return Err(SearchError::TooManyRedirects { max: MAX_REDIRECTS });
322        }
323        validate_public_url(next.as_str())?;
324        current = next;
325        redirect_count += 1;
326    };
327
328    let final_url = current.to_string();
329
330    let status = response.status().as_u16();
331    let content_type = response
332        .headers()
333        .get("Content-Type")
334        .and_then(|v| v.to_str().ok())
335        .unwrap_or("")
336        .to_string();
337
338    let kind = allowed_content_kind(&content_type).ok_or_else(|| SearchError::BadContentType(content_type.clone()))?;
339
340    let body_result = response
341        .into_body()
342        .with_config()
343        .limit(MAX_RESPONSE_BYTES as u64)
344        .read_to_string();
345
346    let (body, body_truncated) = match body_result {
347        Ok(s) => (s, false),
348        Err(ureq::Error::BodyExceedsLimit(_)) => (String::new(), true),
349        Err(e) => return Err(SearchError::Http(e.to_string())),
350    };
351
352    let content = process_body(&body, &final_url, kind)?;
353
354    let truncated = body_truncated || content.truncated;
355
356    let mut diagnostics = vec![
357        format!("status: {status}"),
358        format!("content_type: {content_type}"),
359        format!("redirects_followed: {redirect_count}"),
360        format!("max_redirects: {MAX_REDIRECTS}"),
361        format!("timeout_secs: {FETCH_TIMEOUT_SECS}"),
362        format!("max_bytes: {MAX_RESPONSE_BYTES}"),
363    ];
364    if truncated {
365        diagnostics.push("truncated: true".to_string());
366    }
367    if url_str != final_url {
368        diagnostics.push(format!("redirected: {url_str} -> {final_url}"));
369    }
370
371    Ok(FetchedContent {
372        final_url,
373        status,
374        title: content.title,
375        markdown: content.markdown,
376        text_content: content.text_content,
377        truncated,
378        diagnostics,
379    })
380}
381
382fn public_fetch_agent(timeout: Duration) -> ureq::Agent {
383    let config = ureq::Agent::config_builder()
384        .max_redirects(0)
385        // A proxy can resolve the destination itself and bypass the guarded
386        // resolver. Public URL fetching therefore always connects directly.
387        .proxy(None)
388        .timeout_global(Some(timeout))
389        .build();
390    ureq::Agent::with_parts(
391        config,
392        DefaultConnector::default(),
393        PublicResolver::new(DefaultResolver::default()),
394    )
395}
396
397fn request_public_url(agent: &ureq::Agent, url: &str) -> Result<ureq::http::Response<ureq::Body>> {
398    match agent
399        .get(url)
400        .header("User-Agent", USER_AGENT)
401        .header("Accept", ALLOWED_ACCEPT_HEADER)
402        .call()
403    {
404        Ok(response) => Ok(response),
405        Err(ureq::Error::StatusCode(code)) => Err(SearchError::HttpStatus { status: code, body: String::new() }),
406        Err(ureq::Error::Timeout(_)) => Err(SearchError::Timeout { secs: FETCH_TIMEOUT_SECS }),
407        Err(ureq::Error::BodyExceedsLimit(limit)) => Err(SearchError::Oversized { max: limit as usize }),
408        Err(ureq::Error::Io(error)) if is_private_resolution_error(&error) => {
409            Err(SearchError::PrivateNetwork(url.to_string()))
410        }
411        Err(error) => Err(SearchError::Http(error.to_string())),
412    }
413}
414
415fn redirect_target(current: &url::Url, response: &ureq::http::Response<ureq::Body>) -> Result<Option<url::Url>> {
416    if !response.status().is_redirection() {
417        return Ok(None);
418    }
419    let Some(location) = response.headers().get("Location") else {
420        return Ok(None);
421    };
422    let location = location
423        .to_str()
424        .map_err(|error| SearchError::Http(format!("invalid redirect location: {error}")))?;
425    let next = current
426        .join(location)
427        .map_err(|error| SearchError::Http(format!("invalid redirect location: {error}")))?;
428    Ok(Some(next))
429}
430
431fn validate_public_url(url_str: &str) -> Result<()> {
432    if !is_public_scheme(url_str) {
433        return Err(SearchError::UnsupportedScheme(url_str.to_string()));
434    }
435    if is_private_url(url_str) {
436        return Err(SearchError::PrivateNetwork(url_str.to_string()));
437    }
438    Ok(())
439}
440
441fn is_private_resolution_error(error: &io::Error) -> bool {
442    error.kind() == io::ErrorKind::PermissionDenied && error.to_string() == PRIVATE_RESOLUTION_ERROR
443}
444
445/// Classify a `Content-Type` header value into a [`ContentKind`] on the allow-list.
446///
447/// Returns `None` for binary types (images, audio, video, archives, octet-stream),
448/// unrecognized types, and non-text application types not explicitly listed.
449///
450/// The check is deliberately permissive about parameters (`; charset=utf-8`) and
451/// tolerates `+json` / `+xml` suffixes (`application/feed+json`, `application/atom+xml`).
452pub fn allowed_content_kind(content_type: &str) -> Option<ContentKind> {
453    let essence = content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
454    if essence.is_empty() {
455        return None;
456    }
457
458    if essence.starts_with("text/") {
459        return Some(html_kind(&essence));
460    }
461
462    if let Some(sub) = essence.strip_prefix("application/") {
463        if sub == "html" || sub == "xhtml+xml" {
464            return Some(ContentKind::Html);
465        }
466
467        if sub == "json" || sub.ends_with("+json") {
468            return Some(ContentKind::Text);
469        }
470
471        if sub == "xml" || sub.ends_with("+xml") {
472            return Some(ContentKind::Text);
473        }
474
475        if matches!(
476            sub,
477            "javascript" | "x-javascript" | "yaml" | "x-yaml" | "x-www-form-urlencoded"
478        ) {
479            return Some(ContentKind::Text);
480        }
481    }
482
483    None
484}
485
486/// Map a `text/*` essence to the right kind (HTML vs plain text).
487fn html_kind(essence: &str) -> ContentKind {
488    match essence {
489        "text/html" | "text/xhtml" => ContentKind::Html,
490        _ => ContentKind::Text,
491    }
492}
493
494/// Process a fetched body according to its [`ContentKind`].
495///
496/// HTML/XHTML is run through Lectito readability extraction; other text types
497/// are returned as raw text with the title derived from the URL path. This is
498/// the no-network, fixture-testable core of [`fetch_url`].
499pub fn process_body(body: &str, final_url: &str, kind: ContentKind) -> Result<ProcessedContent> {
500    match kind {
501        ContentKind::Html => {
502            let article = extract_article(body, Some(final_url))?;
503            match article {
504                Some(a) => Ok(ProcessedContent {
505                    title: a.title,
506                    markdown: cap_text(&a.markdown, MAX_ARTICLE_CONTENT_LEN),
507                    text_content: cap_text(&a.text_content, MAX_ARTICLE_CONTENT_LEN),
508                    truncated: a.truncated,
509                }),
510
511                None => Ok(ProcessedContent {
512                    title: title_from_url(final_url),
513                    markdown: cap_text(body, MAX_ARTICLE_CONTENT_LEN),
514                    text_content: cap_text(body, MAX_ARTICLE_CONTENT_LEN),
515                    truncated: body.len() > MAX_ARTICLE_CONTENT_LEN,
516                }),
517            }
518        }
519        ContentKind::Text => Ok(ProcessedContent {
520            title: title_from_url(final_url),
521            markdown: cap_text(body, MAX_ARTICLE_CONTENT_LEN),
522            text_content: cap_text(body, MAX_ARTICLE_CONTENT_LEN),
523            truncated: body.len() > MAX_ARTICLE_CONTENT_LEN,
524        }),
525    }
526}
527
528/// Derive a best-effort title from the final URL path.
529fn title_from_url(url_str: &str) -> String {
530    let Ok(parsed) = url::Url::parse(url_str) else {
531        return String::new();
532    };
533    let path = parsed.path();
534    let last = path.rsplit('/').find(|s| !s.is_empty()).unwrap_or("");
535    percent_decode(last).unwrap_or_default()
536}
537
538/// Content category after allow-list classification.
539#[derive(Clone, Copy, Debug, PartialEq, Eq)]
540pub enum ContentKind {
541    /// HTML/XHTML — run through Lectito readability extraction.
542    Html,
543    /// Other text types (JSON, XML, plain text, feeds, YAML, JS) — raw body.
544    Text,
545}
546
547/// Processed body content, independent of transport details.
548#[derive(Clone, Debug, PartialEq, Eq)]
549pub struct ProcessedContent {
550    /// Derived or extracted title.
551    pub title: String,
552    /// Markdown-formatted content.
553    pub markdown: String,
554    /// Plain text content.
555    pub text_content: String,
556    /// Whether the content was truncated to fit the size cap.
557    pub truncated: bool,
558}
559
560/// Comma-separated `Accept` header value advertising the allow-listed types.
561const ALLOWED_ACCEPT_HEADER: &str = "text/html, application/xhtml+xml, text/plain, \
562    text/markdown, text/css, text/csv, text/xml, application/json, application/xml, \
563    application/javascript, application/yaml, application/rss+xml, application/atom+xml, \
564    application/feed+json, */+json, */+xml;q=0.5";
565
566/// Search DuckDuckGo and return up to `limit` parsed results.
567///
568/// Uses a sync `ureq` POST to `html.duckduckgo.com/html/`. Empty queries and
569/// zero limits return an empty result set without a network request. The
570/// request shares the bounded fetch timeout so a stalled search cannot hold an
571/// agent worker indefinitely.
572pub fn search_duckduckgo(query: &str, limit: usize) -> Result<Vec<SearchResult>> {
573    let query = query.trim();
574    if query.is_empty() || limit == 0 {
575        return Ok(Vec::new());
576    }
577
578    let form = url::form_urlencoded::Serializer::new(String::new())
579        .append_pair("q", query)
580        .append_pair("b", "")
581        .append_pair("l", "us-en")
582        .finish();
583
584    let agent = duckduckgo_agent();
585    let response = agent
586        .post(DUCKDUCKGO_HTML_URL)
587        .header("User-Agent", USER_AGENT)
588        .header("Accept", "text/html,application/xhtml+xml")
589        .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
590        .send(&form);
591
592    let response = match response {
593        Ok(r) => r,
594        Err(ureq::Error::Timeout(_)) => return Err(SearchError::Timeout { secs: FETCH_TIMEOUT_SECS }),
595        Err(e) => return Err(SearchError::Http(e.to_string())),
596    };
597    let (status, body) = read_response_body(response, MAX_SEARCH_RESPONSE_BYTES)?;
598    if status >= 400 {
599        return Err(SearchError::HttpStatus { status, body: body.chars().take(500).collect() });
600    }
601    parse_duckduckgo_html(&body, cap_search_limit(limit))
602}
603
604/// Search with the configured application-owned backend.
605pub fn search_with_config(config: &SearchConfig, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
606    config.validate()?;
607    let limit = cap_search_limit(limit);
608    match config.backend {
609        WebSearchMode::DuckDuckGo => search_duckduckgo(query, limit),
610        WebSearchMode::Searxng => search_searxng(config.searxng_url.as_deref().unwrap_or_default(), query, limit),
611        WebSearchMode::None => Err(SearchError::Disabled),
612    }
613}
614
615// TODO: Add Exa as another application-owned backend when its API contract is
616// stable enough to fit this normalized result boundary.
617// TODO: Add Tavily as another application-owned backend behind the same caps.
618// TODO: Add Brave as another application-owned backend without reintroducing
619// provider-specific request payloads or headers.
620
621/// Search and best-effort fetch each selected result page in result order.
622pub fn search_and_extract(config: &SearchConfig, query: &str, limit: usize) -> Result<Vec<SearchResultContent>> {
623    let results = search_with_config(config, query, limit)?;
624    Ok(extract_result_pages(results, fetch_url))
625}
626
627/// Extract result pages with a caller-supplied fetcher.
628///
629/// The real tool passes [`fetch_url`]. Keeping this small boundary injectable
630/// makes ordering, partial-success, limit, and SSRF-policy behavior testable
631/// without relying on public internet pages.
632pub fn extract_result_pages<F>(results: Vec<SearchResult>, mut fetcher: F) -> Vec<SearchResultContent>
633where
634    F: FnMut(&str) -> Result<FetchedContent>,
635{
636    results
637        .into_iter()
638        .map(|result| match fetcher(&result.url) {
639            Ok(content) => SearchResultContent { result, content: Some(content), error: None },
640            Err(error) => SearchResultContent { result, content: None, error: Some(error.to_string()) },
641        })
642        .collect()
643}
644
645/// Search a SearXNG JSON endpoint and return normalized results.
646///
647/// SearXNG's configured base URL may be loopback or private because it is an
648/// explicit local service. URLs returned in its result list are not trusted;
649/// page extraction still goes through [`fetch_url`] and its public-target
650/// checks.
651pub fn search_searxng(base_url: &str, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
652    let query = query.trim();
653    if query.is_empty() || limit == 0 {
654        return Ok(Vec::new());
655    }
656    let endpoint = searxng_search_url(base_url)?;
657    let mut url = endpoint;
658    url.query_pairs_mut()
659        .append_pair("q", query)
660        .append_pair("format", "json");
661
662    let response = searxng_agent()
663        .get(url.as_str())
664        .header("User-Agent", USER_AGENT)
665        .header("Accept", "application/json")
666        .config()
667        .http_status_as_error(false)
668        .build()
669        .call()
670        .map_err(|error| match error {
671            ureq::Error::TooManyRedirects => SearchError::TooManyRedirects { max: MAX_REDIRECTS },
672            ureq::Error::Timeout(_) => SearchError::Timeout { secs: FETCH_TIMEOUT_SECS },
673            other => SearchError::Http(other.to_string()),
674        })?;
675    let (status, body) = read_response_body(response, MAX_SEARCH_RESPONSE_BYTES)?;
676    if status >= 400 {
677        return Err(SearchError::HttpStatus { status, body: body.chars().take(500).collect() });
678    }
679    parse_searxng_json(&body, cap_search_limit(limit))
680}
681
682/// Parse SearXNG's normalized JSON result envelope.
683pub fn parse_searxng_json(json: &str, limit: usize) -> Result<Vec<SearchResult>> {
684    if limit == 0 {
685        return Ok(Vec::new());
686    }
687    let value =
688        serde_json::from_str::<serde_json::Value>(json).map_err(|error| SearchError::Json(error.to_string()))?;
689    let Some(results) = value.get("results").and_then(serde_json::Value::as_array) else {
690        return Err(SearchError::Json(
691            "response did not contain a results array".to_string(),
692        ));
693    };
694
695    let mut normalized = Vec::new();
696    for result in results {
697        let Some(url) = result.get("url").and_then(serde_json::Value::as_str).map(str::trim) else {
698            continue;
699        };
700        let Some(url) = normalize_result_url(url) else {
701            continue;
702        };
703        let title = result
704            .get("title")
705            .and_then(serde_json::Value::as_str)
706            .map(clean_text)
707            .filter(|title| !title.is_empty())
708            .unwrap_or_else(|| url.clone());
709        let snippet = result
710            .get("content")
711            .or_else(|| result.get("snippet"))
712            .and_then(serde_json::Value::as_str)
713            .map(clean_text)
714            .filter(|snippet| !snippet.is_empty());
715        normalized.push(SearchResult { title, url, snippet });
716        if normalized.len() >= limit {
717            break;
718        }
719    }
720    Ok(normalized)
721}
722
723/// Parse DuckDuckGo HTML search results.
724///
725/// Detects the common bot-challenge page before returning results. Normalizes
726/// DuckDuckGo redirect links (`/l/?uddg=...`) into their target URLs.
727pub fn parse_duckduckgo_html(html: &str, limit: usize) -> Result<Vec<SearchResult>> {
728    if limit == 0 {
729        return Ok(Vec::new());
730    }
731
732    if is_bot_challenge(html) {
733        return Err(SearchError::Blocked(
734            "DuckDuckGo returned a bot challenge instead of search results".to_string(),
735        ));
736    }
737
738    let document = Html::parse_document(html);
739    let result_selector = selector(".result")?;
740    let title_selector = selector(".result__title a, a.result__a")?;
741    let snippet_selector = selector(".result__snippet")?;
742    let url_selector = selector(".result__url")?;
743    let mut results = Vec::new();
744
745    for result in document.select(&result_selector) {
746        let Some(link) = result.select(&title_selector).next() else {
747            continue;
748        };
749        let Some(href) = link.value().attr("href") else {
750            continue;
751        };
752
753        let title = clean_text(&link.text().collect::<Vec<_>>().join(" "));
754        if title.is_empty() {
755            continue;
756        }
757
758        let snippet = result
759            .select(&snippet_selector)
760            .next()
761            .map(|node| clean_text(&node.text().collect::<Vec<_>>().join(" ")))
762            .filter(|text| !text.is_empty());
763        let fallback_url = result
764            .select(&url_selector)
765            .next()
766            .map(|node| clean_text(&node.text().collect::<Vec<_>>().join(" ")))
767            .filter(|text| !text.is_empty());
768
769        match normalize_duckduckgo_url(href).or(fallback_url) {
770            Some(url) => {
771                results.push(SearchResult { title, url, snippet });
772                if results.len() >= limit {
773                    break;
774                }
775            }
776            None => continue,
777        }
778    }
779
780    Ok(results)
781}
782
783/// Detect DuckDuckGo bot-challenge / anomaly pages.
784///
785/// Checks for known markers that DDG uses when it suspects automated traffic.
786pub fn is_bot_challenge(html: &str) -> bool {
787    html.contains("anomaly-modal")
788        || html.contains("Unfortunately, bots use DuckDuckGo too")
789        || html.contains("/anomaly.js")
790}
791
792/// Extract readable content from already-fetched HTML using Lectito.
793///
794/// Returns the article title, Markdown content, and a truncation flag.
795/// Returns `None` if the page is not probably readable.
796pub fn extract_article(html: &str, base_url: Option<&str>) -> Result<Option<ArticleContent>> {
797    let options = lectito::ReadabilityOptions::default();
798    let article = lectito::extract(html, base_url, &options).map_err(|e| SearchError::Extraction(e.to_string()))?;
799
800    match article {
801        Some(a) => Ok(Some(ArticleContent {
802            title: a.title.unwrap_or_default(),
803            markdown: cap_text(&a.markdown, MAX_ARTICLE_CONTENT_LEN),
804            text_content: cap_text(&a.text_content, MAX_ARTICLE_CONTENT_LEN),
805            truncated: a.markdown.len() > MAX_ARTICLE_CONTENT_LEN,
806        })),
807        None => Ok(None),
808    }
809}
810
811/// Format search results as transcript output lines.
812pub fn format_search_results(results: &[SearchResult]) -> Vec<String> {
813    let content = results
814        .iter()
815        .cloned()
816        .map(|result| SearchResultContent { result, content: None, error: None })
817        .collect::<Vec<_>>();
818    format_search_results_with_content(&content)
819}
820
821/// Format normalized results and best-effort page extraction with a total cap.
822pub fn format_search_results_with_content(results: &[SearchResultContent]) -> Vec<String> {
823    let mut output = String::new();
824    for (index, result) in results.iter().enumerate() {
825        let snippet = result.result.snippet.as_deref().unwrap_or("(no snippet)");
826        let mut lines = vec![
827            format!("{}. {} — {}", index + 1, result.result.title, snippet),
828            format!("   url: {}", result.result.url),
829        ];
830        if let Some(content) = &result.content {
831            lines.push(format!("   extracted_title: {}", content.title));
832            lines.push(format!("   final_url: {}", content.final_url));
833            if content.truncated {
834                lines.push("   extracted_content: (truncated)".to_string());
835            }
836            lines.extend(content.markdown.lines().map(|line| format!("   {line}")));
837            if !content.diagnostics.is_empty() {
838                lines.push(format!("   diagnostics: {}", content.diagnostics.join(", ")));
839            }
840        } else if let Some(error) = &result.error {
841            lines.push(format!("   extraction_error: {error}"));
842        }
843
844        for line in lines {
845            let line = cap_text(&line, MAX_SEARCH_OUTPUT_BYTES);
846            if output.len() + line.len() + 1 > MAX_SEARCH_OUTPUT_BYTES {
847                output.push_str("[search output truncated]\n");
848                return output.lines().map(str::to_string).collect();
849            }
850            output.push_str(&line);
851            output.push('\n');
852        }
853    }
854    output.lines().map(str::to_string).collect()
855}
856
857/// Create the bounded transport used by the local DuckDuckGo fallback.
858fn duckduckgo_agent() -> ureq::Agent {
859    let config = ureq::Agent::config_builder()
860        .max_redirects(MAX_REDIRECTS)
861        .max_redirects_will_error(true)
862        .timeout_global(Some(Duration::from_secs(FETCH_TIMEOUT_SECS)))
863        .build();
864    ureq::Agent::new_with_config(config)
865}
866
867/// Create the bounded transport used by a configured SearXNG backend.
868fn searxng_agent() -> ureq::Agent {
869    ureq::Agent::config_builder()
870        .max_redirects(MAX_REDIRECTS)
871        .max_redirects_will_error(true)
872        .timeout_global(Some(Duration::from_secs(FETCH_TIMEOUT_SECS)))
873        .build()
874        .new_agent()
875}
876
877fn searxng_search_url(base_url: &str) -> Result<url::Url> {
878    validate_search_base_url(base_url)?;
879    let mut base = url::Url::parse(base_url.trim_end_matches('/'))
880        .map_err(|error| SearchError::InvalidConfiguration(error.to_string()))?;
881    let path = format!("{}/search", base.path().trim_end_matches('/'));
882    base.set_path(if path == "/search" { "/search" } else { &path });
883    Ok(base)
884}
885
886fn validate_search_base_url(base_url: &str) -> Result<()> {
887    let parsed = url::Url::parse(base_url)
888        .map_err(|error| SearchError::InvalidConfiguration(format!("invalid base URL: {error}")))?;
889    if !matches!(parsed.scheme(), "http" | "https") {
890        return Err(SearchError::InvalidConfiguration(
891            "SearXNG base URL must use HTTP or HTTPS".to_string(),
892        ));
893    }
894    if parsed.host_str().is_none() {
895        return Err(SearchError::InvalidConfiguration(
896            "SearXNG base URL must include a host".to_string(),
897        ));
898    }
899    if parsed.query().is_some() || parsed.fragment().is_some() {
900        return Err(SearchError::InvalidConfiguration(
901            "SearXNG base URL must not include a query or fragment".to_string(),
902        ));
903    }
904    Ok(())
905}
906
907fn normalize_result_url(value: &str) -> Option<String> {
908    let parsed = url::Url::parse(value).ok()?;
909    if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
910        return None;
911    }
912    Some(parsed.to_string())
913}
914
915fn cap_search_limit(limit: usize) -> usize {
916    limit.min(MAX_SEARCH_LIMIT)
917}
918
919fn read_response_body(response: ureq::http::Response<ureq::Body>, limit: usize) -> Result<(u16, String)> {
920    let status = response.status().as_u16();
921    let body = response
922        .into_body()
923        .with_config()
924        .limit(limit as u64)
925        .read_to_string()
926        .map_err(|error| match error {
927            ureq::Error::BodyExceedsLimit(_) => SearchError::Oversized { max: limit },
928            ureq::Error::Timeout(_) => SearchError::Timeout { secs: FETCH_TIMEOUT_SECS },
929            other => SearchError::Http(other.to_string()),
930        })?;
931    Ok((status, body))
932}
933
934fn cap_text(text: &str, max_bytes: usize) -> String {
935    if text.len() <= max_bytes {
936        return text.to_string();
937    }
938    let mut end = max_bytes.saturating_sub("…".len());
939    while !text.is_char_boundary(end) {
940        end = end.saturating_sub(1);
941    }
942    format!("{}…", &text[..end])
943}
944
945fn is_public_ip(ip: IpAddr) -> bool {
946    match ip {
947        IpAddr::V4(ip) => is_public_ipv4(ip),
948        IpAddr::V6(ip) => is_public_ipv6(ip),
949    }
950}
951
952/// Return whether an IPv4 address is globally routable. The explicit special
953/// ranges keep this compatible with the project's Rust 1.88 MSRV.
954fn is_public_ipv4(ip: Ipv4Addr) -> bool {
955    let [a, b, c, _] = ip.octets();
956    !(a == 0
957        || a == 10
958        || a == 127
959        || (a == 100 && (64..=127).contains(&b))
960        || (a == 169 && b == 254)
961        || (a == 172 && (16..=31).contains(&b))
962        || (a == 192 && b == 0 && c == 0)
963        || (a == 192 && b == 0 && c == 2)
964        || (a == 192 && b == 88 && c == 99)
965        || (a == 192 && b == 168)
966        || (a == 198 && (b == 18 || b == 19))
967        || (a == 198 && b == 51 && c == 100)
968        || (a == 203 && b == 0 && c == 113)
969        || a >= 224)
970}
971
972/// Return whether an IPv6 address is globally routable. IPv4-mapped addresses
973/// inherit the IPv4 classification; local, documentation, benchmarking, and
974/// transition ranges are rejected conservatively.
975fn is_public_ipv6(ip: Ipv6Addr) -> bool {
976    if let Some(ipv4) = ip.to_ipv4_mapped() {
977        return is_public_ipv4(ipv4);
978    }
979
980    let segments = ip.segments();
981    let first = segments[0];
982    let second = segments[1];
983    let global_unicast = (first & 0xe000) == 0x2000;
984    let teredo = first == 0x2001 && second == 0;
985    let benchmarking = first == 0x2001 && second == 2 && segments[2] == 0;
986    let orchid = first == 0x2001 && (second & 0xfff0 == 0x0010 || second & 0xfff0 == 0x0020);
987    let documentation = (first == 0x2001 && second == 0x0db8) || (first == 0x3fff && second & 0xf000 == 0);
988
989    global_unicast && !(teredo || benchmarking || orchid || documentation || first == 0x2002)
990}
991
992/// Minimal percent-decoding without an extra dependency.
993fn percent_decode(s: &str) -> Option<String> {
994    let mut result = String::with_capacity(s.len());
995    let bytes = s.as_bytes();
996    let mut i = 0;
997    while i < bytes.len() {
998        if bytes[i] == b'%'
999            && i + 2 < bytes.len()
1000            && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
1001        {
1002            result.push(char::from_u32(hi * 16 + lo).unwrap_or('?'));
1003            i += 3;
1004            continue;
1005        }
1006        if bytes[i] == b'+' {
1007            result.push(' ');
1008        } else {
1009            result.push(s[i..].chars().next().unwrap_or('?'));
1010        }
1011        i += 1;
1012    }
1013    Some(result)
1014}
1015
1016fn selector(css: &'static str) -> Result<Selector> {
1017    Selector::parse(css).map_err(|_| SearchError::InvalidSelector(css))
1018}
1019
1020fn clean_text(text: &str) -> String {
1021    text.split_whitespace().collect::<Vec<_>>().join(" ")
1022}
1023
1024fn normalize_duckduckgo_url(href: &str) -> Option<String> {
1025    let decoded = html_unescape(href);
1026    if decoded.starts_with("http://") || decoded.starts_with("https://") {
1027        return Some(decoded);
1028    }
1029
1030    if let Some(query_start) = decoded.find("uddg=") {
1031        let encoded = &decoded[query_start + 5..];
1032        let end = encoded.find('&').unwrap_or(encoded.len());
1033        return percent_decode(&encoded[..end]);
1034    }
1035
1036    let base = url::Url::parse(DUCKDUCKGO_HTML_URL).ok()?;
1037    Some(base.join(&decoded).ok()?.to_string())
1038}
1039
1040fn hex_val(b: u8) -> Option<u32> {
1041    match b {
1042        b'0'..=b'9' => Some((b - b'0') as u32),
1043        b'a'..=b'f' => Some((b - b'a' + 10) as u32),
1044        b'A'..=b'F' => Some((b - b'A' + 10) as u32),
1045        _ => None,
1046    }
1047}
1048
1049fn html_unescape(text: &str) -> String {
1050    text.replace("&amp;", "&")
1051        .replace("&lt;", "<")
1052        .replace("&gt;", ">")
1053        .replace("&quot;", "\"")
1054        .replace("&#x27;", "'")
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use std::io::{Read, Write};
1060    use std::net::{SocketAddr, TcpListener};
1061    use std::sync::Arc;
1062    use std::sync::atomic::{AtomicUsize, Ordering};
1063
1064    use super::*;
1065
1066    #[derive(Clone, Debug)]
1067    struct FixedResolver {
1068        address: SocketAddr,
1069    }
1070
1071    impl Resolver for FixedResolver {
1072        fn resolve(
1073            &self, _uri: &ureq::http::Uri, _config: &ureq::config::Config, _timeout: NextTimeout,
1074        ) -> std::result::Result<ResolvedSocketAddrs, ureq::Error> {
1075            let mut addresses = self.empty();
1076            addresses.push(self.address);
1077            Ok(addresses)
1078        }
1079    }
1080
1081    fn test_agent(timeout: Duration, resolver: impl Resolver) -> ureq::Agent {
1082        let config = ureq::Agent::config_builder()
1083            .max_redirects(0)
1084            .timeout_global(Some(timeout))
1085            .build();
1086        ureq::Agent::with_parts(config, DefaultConnector::default(), resolver)
1087    }
1088
1089    fn spawn_http_response(response: String) -> (String, std::thread::JoinHandle<()>) {
1090        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test server");
1091        let address = listener.local_addr().expect("test server address");
1092        let handle = std::thread::spawn(move || {
1093            let Ok((mut stream, _)) = listener.accept() else {
1094                return;
1095            };
1096            let mut request = [0_u8; 1024];
1097            let _ = stream.read(&mut request);
1098            let _ = stream.write_all(response.as_bytes());
1099        });
1100        (format!("http://{address}"), handle)
1101    }
1102
1103    #[test]
1104    fn parse_duckduckgo_html_extracts_results() {
1105        let html = r#"
1106            <div class="result">
1107              <h2 class="result__title">
1108                <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpost%3Fx%3D1&amp;rut=abc">
1109                  Example Result
1110                </a>
1111              </h2>
1112              <a class="result__url">example.com/post</a>
1113              <a class="result__snippet">A compact result snippet.</a>
1114            </div>
1115        "#;
1116
1117        let results = parse_duckduckgo_html(html, 10).expect("html parses");
1118        assert_eq!(results.len(), 1);
1119        assert_eq!(results[0].title, "Example Result");
1120        assert_eq!(results[0].url, "https://example.com/post?x=1");
1121        assert_eq!(results[0].snippet.as_deref(), Some("A compact result snippet."));
1122    }
1123
1124    #[test]
1125    fn parse_duckduckgo_html_respects_limit() {
1126        let html = r#"
1127            <div class="result"><a class="result__a" href="https://a.test">A</a></div>
1128            <div class="result"><a class="result__a" href="https://b.test">B</a></div>
1129        "#;
1130
1131        let results = parse_duckduckgo_html(html, 1).expect("html parses");
1132        assert_eq!(results.len(), 1);
1133        assert_eq!(results[0].title, "A");
1134    }
1135
1136    #[test]
1137    fn parse_duckduckgo_html_detects_bot_challenge() {
1138        let html = r#"
1139            <form id="challenge-form" action="//duckduckgo.com/anomaly.js">
1140              <div class="anomaly-modal__title">
1141                Unfortunately, bots use DuckDuckGo too.
1142              </div>
1143            </form>
1144        "#;
1145
1146        let error = parse_duckduckgo_html(html, 10).expect_err("challenge is an error");
1147        assert!(matches!(error, SearchError::Blocked(_)));
1148    }
1149
1150    #[test]
1151    fn is_bot_challenge_detects_anomaly_markers() {
1152        assert!(is_bot_challenge("anomaly-modal test"));
1153        assert!(is_bot_challenge("Unfortunately, bots use DuckDuckGo too"));
1154        assert!(is_bot_challenge("/anomaly.js"));
1155        assert!(!is_bot_challenge("normal search results"));
1156    }
1157
1158    #[test]
1159    fn is_bot_challenge_false_for_normal_html() {
1160        let html = r#"<div class="result"><a href="https://example.com">Normal</a></div>"#;
1161        assert!(!is_bot_challenge(html));
1162    }
1163
1164    #[test]
1165    fn empty_query_returns_empty_results() {
1166        let results = parse_duckduckgo_html("", 10).expect("empty html");
1167        assert!(results.is_empty());
1168    }
1169
1170    #[test]
1171    fn zero_limit_returns_empty_results() {
1172        let html = r#"<div class="result"><a href="https://example.com">Test</a></div>"#;
1173        let results = parse_duckduckgo_html(html, 0).expect("zero limit");
1174        assert!(results.is_empty());
1175    }
1176
1177    #[test]
1178    fn normalize_duckduckgo_url_resolves_redirect() {
1179        let url = normalize_duckduckgo_url("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpost");
1180        assert_eq!(url.as_deref(), Some("https://example.com/post"));
1181    }
1182
1183    #[test]
1184    fn normalize_duckduckgo_url_passes_through_absolute() {
1185        let url = normalize_duckduckgo_url("https://example.com/direct");
1186        assert_eq!(url.as_deref(), Some("https://example.com/direct"));
1187    }
1188
1189    #[test]
1190    fn default_search_limit_is_small() {
1191        let limit = DEFAULT_SEARCH_LIMIT;
1192        assert!(limit <= 10, "search limit should be small");
1193        assert!(limit >= 3, "search limit should be useful");
1194    }
1195
1196    #[test]
1197    fn format_search_results_produces_lines() {
1198        let lines = format_search_results(&[
1199            SearchResult {
1200                title: "Rust Async".to_string(),
1201                url: "https://tokio.rs".to_string(),
1202                snippet: Some("Async runtime".to_string()),
1203            },
1204            SearchResult {
1205                title: "Async Book".to_string(),
1206                url: "https://rust-lang.org/async".to_string(),
1207                snippet: None,
1208            },
1209        ]);
1210        assert_eq!(lines.len(), 4);
1211        assert!(lines[0].contains("Rust Async"));
1212        assert!(lines[1].contains("tokio.rs"));
1213        assert!(lines[2].contains("Async Book"));
1214    }
1215
1216    #[test]
1217    fn extract_article_returns_none_for_empty_html() {
1218        assert!(
1219            extract_article("<html><body></body></html>", None)
1220                .expect("should not error")
1221                .is_none()
1222        );
1223    }
1224
1225    #[test]
1226    fn extract_article_returns_content_for_readable_html() {
1227        let html = r#"
1228            <article>
1229                <h1>Test Article</h1>
1230                <p>This is a readable article with enough content to pass readability checks.
1231                It has multiple sentences and proper structure for extraction.</p>
1232                <p>Second paragraph with more content to ensure the article is detected
1233                as readable by the Lectito algorithm.</p>
1234            </article>
1235        "#;
1236        let result = extract_article(html, Some("https://example.com/post")).expect("should extract");
1237        assert!(result.is_some(), "should extract readable article");
1238
1239        let article = result.unwrap();
1240        assert!(!article.markdown.is_empty());
1241        assert!(!article.text_content.is_empty());
1242    }
1243
1244    #[test]
1245    fn is_private_url_table() {
1246        let rejected: &[(&str, &str)] = &[
1247            ("localhost", "http://localhost:8080/test"),
1248            ("localhost dot", "https://localhost./path"),
1249            ("loopback ipv4", "http://127.0.0.1/test"),
1250            ("loopback ipv4 high", "http://127.255.255.255/test"),
1251            ("private 10.x", "http://10.0.0.1/test"),
1252            ("private 10.x high", "http://10.255.255.255/test"),
1253            ("private 172.16.x", "http://172.16.0.1/test"),
1254            ("private 172.31.x high", "http://172.31.255.255/test"),
1255            ("private 192.168.x", "http://192.168.1.1/test"),
1256            ("private 192.168.0", "http://192.168.0.0/test"),
1257            ("link-local", "http://169.254.1.1/test"),
1258            ("link-local metadata", "http://169.254.169.254/latest/meta-data"),
1259            ("shared address space", "http://100.64.0.1/test"),
1260            ("benchmarking", "http://198.18.0.1/test"),
1261            ("documentation", "http://203.0.113.10/test"),
1262            ("multicast", "http://224.0.0.1/test"),
1263            ("zero address", "http://0.0.0.0/test"),
1264            ("ipv6 loopback", "http://[::1]/test"),
1265            ("ipv6 mapped loopback", "http://[::ffff:127.0.0.1]/test"),
1266            ("ipv6 documentation", "http://[2001:db8::1]/test"),
1267            ("ipv6 documentation 3fff", "http://[3fff::1]/test"),
1268            ("ipv6 unique local", "http://[fd00::1]/test"),
1269            ("ipv6 reserved", "http://[4000::1]/test"),
1270            ("file scheme", "file:///etc/passwd"),
1271            ("ftp scheme", "ftp://example.com/file"),
1272            ("javascript scheme", "javascript:alert(1)"),
1273            ("unparseable", "not a url"),
1274            ("empty", ""),
1275        ];
1276        for (label, url) in rejected {
1277            assert!(
1278                is_private_url(url),
1279                "{label}: expected private/rejected, got allowed: {url}"
1280            );
1281        }
1282
1283        let allowed: &[(&str, &str)] = &[
1284            ("public domain", "https://example.com/article"),
1285            ("public ipv4", "http://93.184.216.34/test"),
1286            ("public ipv6", "https://[2606:2800:220:1:248:1893:25c8:1946]/test"),
1287            ("public blog", "https://blog.rust-lang.org/2024/01/01/post"),
1288        ];
1289        for (label, url) in allowed {
1290            assert!(!is_private_url(url), "{label}: expected allowed, got rejected: {url}");
1291        }
1292    }
1293
1294    #[test]
1295    fn is_public_scheme_checks_prefix() {
1296        assert!(is_public_scheme("http://example.com"));
1297        assert!(is_public_scheme("https://example.com"));
1298        assert!(!is_public_scheme("file:///etc/passwd"));
1299        assert!(!is_public_scheme("ftp://example.com"));
1300    }
1301
1302    #[test]
1303    fn fetch_url_rejects_non_public_scheme() {
1304        let result = fetch_url("file:///etc/passwd");
1305        assert!(matches!(result, Err(SearchError::UnsupportedScheme(_))));
1306    }
1307
1308    #[test]
1309    fn fetch_url_rejects_private_network() {
1310        let result = fetch_url("http://127.0.0.1:8080/secret");
1311        assert!(matches!(result, Err(SearchError::PrivateNetwork(_))));
1312
1313        let result = fetch_url("http://localhost/admin");
1314        assert!(matches!(result, Err(SearchError::PrivateNetwork(_))));
1315    }
1316
1317    #[test]
1318    fn fetch_url_rejects_domain_that_resolves_to_private_address_before_connecting() {
1319        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test server");
1320        listener.set_nonblocking(true).expect("nonblocking test server");
1321        let address = listener.local_addr().expect("test server address");
1322        let url = format!("http://public.test:{}/secret", address.port());
1323
1324        let result = fetch_url_with_agent_factory(&url, |timeout| {
1325            test_agent(timeout, PublicResolver::new(FixedResolver { address }))
1326        });
1327
1328        assert!(matches!(result, Err(SearchError::PrivateNetwork(target)) if target == url));
1329        let error = listener
1330            .accept()
1331            .expect_err("private destination must not receive a connection");
1332        assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
1333    }
1334
1335    #[test]
1336    fn fetch_url_rejects_private_redirect_before_second_request() {
1337        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test server");
1338        let address = listener.local_addr().expect("test server address");
1339        let requests = Arc::new(AtomicUsize::new(0));
1340        let requests_for_server = requests.clone();
1341        let handle = std::thread::spawn(move || {
1342            let (mut stream, _) = listener.accept().expect("first request");
1343            requests_for_server.fetch_add(1, Ordering::SeqCst);
1344            let mut request = [0_u8; 1024];
1345            let _ = stream.read(&mut request);
1346            let response = format!(
1347                "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/secret\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1348                address.port()
1349            );
1350            stream.write_all(response.as_bytes()).expect("redirect response");
1351            listener.set_nonblocking(true).expect("nonblocking test server");
1352            for _ in 0..20 {
1353                match listener.accept() {
1354                    Ok((_stream, _)) => {
1355                        requests_for_server.fetch_add(1, Ordering::SeqCst);
1356                        break;
1357                    }
1358                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
1359                        std::thread::sleep(Duration::from_millis(5));
1360                    }
1361                    Err(_) => break,
1362                }
1363            }
1364        });
1365        let url = format!("http://public.test:{}/start", address.port());
1366
1367        let result = fetch_url_with_agent_factory(&url, |timeout| test_agent(timeout, FixedResolver { address }));
1368
1369        assert!(matches!(result, Err(SearchError::PrivateNetwork(target)) if target.contains("127.0.0.1")));
1370        handle.join().expect("test server");
1371        assert_eq!(
1372            requests.load(Ordering::SeqCst),
1373            1,
1374            "redirect target must not receive a request"
1375        );
1376    }
1377
1378    #[test]
1379    fn max_response_bytes_is_reasonable() {
1380        let max = MAX_RESPONSE_BYTES;
1381        assert!(max >= 65_536, "should allow at least 64 KiB");
1382        assert!(max <= 2_097_152, "should cap at 2 MiB");
1383    }
1384
1385    #[test]
1386    fn max_redirects_is_bounded_and_small() {
1387        let max = MAX_REDIRECTS;
1388        assert!(max <= 5, "redirect limit should be small");
1389        assert!(max >= 1, "should follow at least one redirect");
1390    }
1391
1392    #[test]
1393    fn fetch_timeout_is_bounded() {
1394        let secs = FETCH_TIMEOUT_SECS;
1395        assert!(secs <= 60, "timeout should be at most 60s");
1396        assert!(secs >= 5, "timeout should allow at least 5s");
1397    }
1398
1399    #[test]
1400    fn duckduckgo_search_uses_the_bounded_fetch_timeout() {
1401        let timeouts = duckduckgo_agent().config().timeouts();
1402        assert_eq!(timeouts.global, Some(Duration::from_secs(FETCH_TIMEOUT_SECS)));
1403    }
1404
1405    #[test]
1406    fn search_error_too_many_redirects_displays_message() {
1407        let max = MAX_REDIRECTS;
1408        let err = SearchError::TooManyRedirects { max };
1409        assert!(err.to_string().contains("too many redirects"));
1410        assert!(err.to_string().contains(&max.to_string()));
1411    }
1412
1413    #[test]
1414    fn search_error_timeout_displays_message() {
1415        let secs = FETCH_TIMEOUT_SECS;
1416        let err = SearchError::Timeout { secs };
1417        assert!(err.to_string().contains("timed out"));
1418        assert!(err.to_string().contains(&secs.to_string()));
1419    }
1420
1421    #[test]
1422    fn search_error_oversized_displays_message() {
1423        let err = SearchError::Oversized { max: 1024 };
1424        assert!(err.to_string().contains("too large"));
1425        assert!(err.to_string().contains("1024"));
1426    }
1427
1428    #[test]
1429    fn allowed_content_kind_html() {
1430        assert_eq!(allowed_content_kind("text/html"), Some(ContentKind::Html));
1431        assert_eq!(
1432            allowed_content_kind("text/html; charset=utf-8"),
1433            Some(ContentKind::Html)
1434        );
1435        assert_eq!(allowed_content_kind("application/xhtml+xml"), Some(ContentKind::Html));
1436        assert_eq!(allowed_content_kind("TEXT/HTML"), Some(ContentKind::Html));
1437    }
1438
1439    #[test]
1440    fn allowed_content_kind_text_family() {
1441        assert_eq!(allowed_content_kind("text/plain"), Some(ContentKind::Text));
1442        assert_eq!(
1443            allowed_content_kind("text/plain; charset=iso-8859-1"),
1444            Some(ContentKind::Text)
1445        );
1446        assert_eq!(allowed_content_kind("text/csv"), Some(ContentKind::Text));
1447        assert_eq!(allowed_content_kind("text/markdown"), Some(ContentKind::Text));
1448        assert_eq!(allowed_content_kind("text/css"), Some(ContentKind::Text));
1449        assert_eq!(allowed_content_kind("text/xml"), Some(ContentKind::Text));
1450    }
1451
1452    #[test]
1453    fn allowed_content_kind_application_text_types() {
1454        assert_eq!(allowed_content_kind("application/json"), Some(ContentKind::Text));
1455        assert_eq!(
1456            allowed_content_kind("application/json; charset=utf-8"),
1457            Some(ContentKind::Text)
1458        );
1459        assert_eq!(allowed_content_kind("application/xml"), Some(ContentKind::Text));
1460        assert_eq!(allowed_content_kind("application/javascript"), Some(ContentKind::Text));
1461        assert_eq!(allowed_content_kind("application/yaml"), Some(ContentKind::Text));
1462        assert_eq!(allowed_content_kind("application/x-yaml"), Some(ContentKind::Text));
1463    }
1464
1465    #[test]
1466    fn allowed_content_kind_suffixes() {
1467        assert_eq!(allowed_content_kind("application/feed+json"), Some(ContentKind::Text));
1468        assert_eq!(
1469            allowed_content_kind("application/vnd.api+json"),
1470            Some(ContentKind::Text)
1471        );
1472        assert_eq!(allowed_content_kind("application/atom+xml"), Some(ContentKind::Text));
1473        assert_eq!(allowed_content_kind("application/rss+xml"), Some(ContentKind::Text));
1474    }
1475
1476    #[test]
1477    fn allowed_content_kind_rejects_binary_and_unknown() {
1478        assert_eq!(allowed_content_kind("image/png"), None);
1479        assert_eq!(allowed_content_kind("application/octet-stream"), None);
1480        assert_eq!(allowed_content_kind("application/pdf"), None);
1481        assert_eq!(allowed_content_kind("application/zip"), None);
1482        assert_eq!(allowed_content_kind("audio/mpeg"), None);
1483        assert_eq!(allowed_content_kind("video/mp4"), None);
1484        assert_eq!(allowed_content_kind("application/octet-stream; charset=binary"), None);
1485        assert_eq!(allowed_content_kind(""), None);
1486        assert_eq!(allowed_content_kind("garbage"), None);
1487    }
1488
1489    #[test]
1490    fn process_body_html_uses_lectito_extraction() {
1491        let html = r#"
1492            <article>
1493                <h1>Test Article</h1>
1494                <p>This is a readable article with enough content to pass readability checks.
1495                It has multiple sentences and proper structure for extraction.</p>
1496                <p>Second paragraph with more content to ensure the article is detected
1497                as readable by the Lectito algorithm.</p>
1498            </article>
1499        "#;
1500        let result = process_body(html, "https://example.com/post", ContentKind::Html).expect("html should process");
1501        assert!(!result.markdown.is_empty());
1502        assert!(!result.text_content.is_empty());
1503        assert!(!result.truncated);
1504    }
1505
1506    #[test]
1507    fn process_body_html_unreadable_falls_back_to_raw() {
1508        let html = "<html><body></body></html>";
1509        let result = process_body(html, "https://example.com/empty", ContentKind::Html)
1510            .expect("unreadable html should not error");
1511        assert_eq!(result.markdown, html);
1512        assert_eq!(result.text_content, html);
1513    }
1514
1515    #[test]
1516    fn process_body_text_returns_raw_with_url_title() {
1517        let body = r#"{"name": "thndrs", "version": "0.1.0"}"#;
1518        let result = process_body(body, "https://example.com/package.json", ContentKind::Text)
1519            .expect("json should process as text");
1520        assert_eq!(result.markdown, body);
1521        assert_eq!(result.text_content, body);
1522        assert_eq!(result.title, "package.json");
1523        assert!(!result.truncated);
1524    }
1525
1526    #[test]
1527    fn process_body_text_title_from_url_path() {
1528        let result = process_body("plain", "https://example.com/docs/readme.txt", ContentKind::Text)
1529            .expect("text should process");
1530        assert_eq!(result.title, "readme.txt");
1531    }
1532
1533    #[test]
1534    fn process_body_text_truncation_flag() {
1535        let body = "a".repeat(MAX_ARTICLE_CONTENT_LEN + 100);
1536        let result =
1537            process_body(&body, "https://example.com/big.txt", ContentKind::Text).expect("large text should process");
1538        assert!(result.truncated);
1539    }
1540
1541    #[test]
1542    fn process_body_text_title_percent_decoded() {
1543        let result = process_body("body", "https://example.com/path/my%20file.txt", ContentKind::Text)
1544            .expect("text should process");
1545        assert_eq!(result.title, "my file.txt");
1546    }
1547
1548    #[test]
1549    fn parse_searxng_json_normalizes_results_and_caps_limit() {
1550        let json = r#"{
1551            "results": [
1552                {"title":"First","url":"https://example.com/one","content":" first   snippet "},
1553                {"title":"Private","url":"http://127.0.0.1:8080/secret","content":"local"},
1554                {"title":"Third","url":"https://example.com/three","content":"third"}
1555            ]
1556        }"#;
1557
1558        let results = parse_searxng_json(json, 2).expect("SearXNG JSON parses");
1559        assert_eq!(results.len(), 2);
1560        assert_eq!(results[0].title, "First");
1561        assert_eq!(results[0].snippet.as_deref(), Some("first snippet"));
1562        assert_eq!(results[1].url, "http://127.0.0.1:8080/secret");
1563    }
1564
1565    #[test]
1566    fn parse_searxng_json_rejects_malformed_and_missing_results() {
1567        assert!(matches!(parse_searxng_json("{not json}", 5), Err(SearchError::Json(_))));
1568        assert!(matches!(parse_searxng_json("{}", 5), Err(SearchError::Json(_))));
1569    }
1570
1571    #[test]
1572    fn parse_searxng_json_accepts_empty_results() {
1573        assert!(
1574            parse_searxng_json(r#"{"results":[]}"#, 5)
1575                .expect("empty response parses")
1576                .is_empty()
1577        );
1578    }
1579
1580    #[test]
1581    fn searxng_configuration_requires_http_base_url_but_allows_loopback() {
1582        assert!(SearchConfig::new(WebSearchMode::Searxng, None).is_err());
1583        assert!(SearchConfig::new(WebSearchMode::Searxng, Some("ftp://localhost".to_string())).is_err());
1584        assert!(SearchConfig::new(WebSearchMode::Searxng, Some("http://127.0.0.1:8080".to_string())).is_ok());
1585    }
1586
1587    #[test]
1588    fn searxng_url_uses_search_path_and_query_parameters() {
1589        let url = searxng_search_url("http://127.0.0.1:8080/searxng/").expect("base URL");
1590        assert_eq!(url.as_str(), "http://127.0.0.1:8080/searxng/search");
1591        let config =
1592            SearchConfig::new(WebSearchMode::Searxng, Some("http://127.0.0.1:8080".to_string())).expect("config");
1593        assert!(matches!(
1594            search_with_config(&config, "", 5),
1595            Ok(results) if results.is_empty()
1596        ));
1597    }
1598
1599    #[test]
1600    fn extract_result_pages_preserves_order_and_partial_failures() {
1601        let results = vec![
1602            SearchResult { title: "one".to_string(), url: "https://example.com/one".to_string(), snippet: None },
1603            SearchResult { title: "private".to_string(), url: "http://127.0.0.1/secret".to_string(), snippet: None },
1604            SearchResult { title: "three".to_string(), url: "https://example.com/three".to_string(), snippet: None },
1605        ];
1606        let extracted = extract_result_pages(results, |url| {
1607            if is_private_url(url) {
1608                Err(SearchError::PrivateNetwork(url.to_string()))
1609            } else {
1610                Ok(FetchedContent {
1611                    final_url: url.to_string(),
1612                    status: 200,
1613                    title: "page".to_string(),
1614                    markdown: "content".to_string(),
1615                    text_content: "content".to_string(),
1616                    truncated: false,
1617                    diagnostics: vec!["fake".to_string()],
1618                })
1619            }
1620        });
1621        assert_eq!(extracted.len(), 3);
1622        assert_eq!(extracted[0].result.title, "one");
1623        assert!(extracted[0].content.is_some());
1624        assert!(extracted[1].content.is_none());
1625        assert!(
1626            extracted[1]
1627                .error
1628                .as_deref()
1629                .is_some_and(|error| error.contains("private network"))
1630        );
1631        assert!(extracted[2].content.is_some());
1632    }
1633
1634    #[test]
1635    fn formatted_search_output_is_capped() {
1636        let result = SearchResultContent {
1637            result: SearchResult {
1638                title: "large".to_string(),
1639                url: "https://example.com/large".to_string(),
1640                snippet: None,
1641            },
1642            content: Some(FetchedContent {
1643                final_url: "https://example.com/large".to_string(),
1644                status: 200,
1645                title: "large".to_string(),
1646                markdown: "x".repeat(MAX_SEARCH_OUTPUT_BYTES * 2),
1647                text_content: String::new(),
1648                truncated: true,
1649                diagnostics: Vec::new(),
1650            }),
1651            error: None,
1652        };
1653        let lines = format_search_results_with_content(&[result]);
1654        let bytes: usize = lines.iter().map(|line| line.len() + 1).sum();
1655        assert!(bytes <= MAX_SEARCH_OUTPUT_BYTES + "[search output truncated]\n".len());
1656        assert!(lines.iter().any(|line| line.contains("truncated")));
1657    }
1658
1659    #[test]
1660    fn search_backend_errors_are_structured() {
1661        let config =
1662            SearchConfig::new(WebSearchMode::Searxng, Some("http://127.0.0.1:1".to_string())).expect("loopback config");
1663        let error = search_with_config(&config, "rust", 1).expect_err("unreachable instance");
1664        assert!(matches!(error, SearchError::Http(_) | SearchError::Timeout { .. }));
1665        assert!(
1666            SearchError::Timeout { secs: FETCH_TIMEOUT_SECS }
1667                .to_string()
1668                .contains("timed out")
1669        );
1670    }
1671
1672    #[test]
1673    fn searxng_http_errors_preserve_status_and_bounded_body() {
1674        let body = r#"{"error":"temporarily unavailable"}"#;
1675        let response = format!(
1676            "HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1677            body.len(),
1678            body
1679        );
1680        let (base_url, handle) = spawn_http_response(response);
1681        let error = search_searxng(&base_url, "rust", 5).expect_err("HTTP error should be structured");
1682        handle.join().expect("test server thread");
1683        assert!(matches!(
1684            error,
1685            SearchError::HttpStatus { status: 503, body } if body.contains("temporarily unavailable")
1686        ));
1687    }
1688
1689    #[test]
1690    fn searxng_local_server_returns_normalized_results() {
1691        let body = r#"{"results":[{"title":"Rust","url":"https://www.rust-lang.org/","content":"A language"},{"title":"Tokio","url":"https://tokio.rs/","snippet":"Async runtime"}]}"#;
1692        let response = format!(
1693            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1694            body.len(),
1695            body
1696        );
1697        let (base_url, handle) = spawn_http_response(response);
1698        let results = search_searxng(&base_url, "rust", 20).expect("local SearXNG response");
1699        handle.join().expect("test server thread");
1700        assert_eq!(results.len(), 2);
1701        assert_eq!(results[0].title, "Rust");
1702        assert_eq!(results[0].snippet.as_deref(), Some("A language"));
1703        assert_eq!(results[1].title, "Tokio");
1704    }
1705
1706    #[test]
1707    #[ignore = "requires public network access"]
1708    fn live_duckduckgo_smoke() {
1709        match search_duckduckgo("Rust programming language", 1) {
1710            Ok(results) => {
1711                assert!(!results.is_empty(), "DuckDuckGo should return at least one result");
1712                assert!(is_public_scheme(&results[0].url));
1713            }
1714            Err(SearchError::Blocked(message)) => {
1715                assert!(message.contains("bot challenge"));
1716            }
1717            Err(error) => panic!("unexpected DuckDuckGo smoke-test error: {error}"),
1718        }
1719    }
1720}