Skip to main content

mermaid_cli/providers/tool/
web_client.rs

1use anyhow::{Result, anyhow};
2use async_trait::async_trait;
3use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
4use mermaid_model::models::retry::retry_transient_http;
5use mermaid_model::utils::{classify_host, truncate_content};
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex, OnceLock, Weak};
10use std::time::Duration;
11use tokio::sync::Semaphore;
12
13use crate::providers::ctx::WebByteBudget;
14
15const MAX_REDIRECTS: usize = 5;
16
17static EXTRACTION_SEMAPHORE: OnceLock<Arc<Semaphore>> = OnceLock::new();
18static DOWNLOAD_LIMITER: OnceLock<DownloadLimiter> = OnceLock::new();
19
20/// Result from a web search
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SearchResult {
23    pub title: String,
24    pub url: String,
25    pub snippet: String,
26    pub full_content: String,
27}
28
29/// Which transport produced a fetched page.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum FetchBackend {
33    Native,
34    OllamaCloud,
35}
36
37impl FetchBackend {
38    #[must_use]
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Native => "native",
42            Self::OllamaCloud => "ollama_cloud",
43        }
44    }
45}
46
47/// How response bytes became the content returned to the model.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum ExtractionMode {
51    Readability,
52    HtmlToMarkdown,
53    PlainText,
54    Markdown,
55    Json,
56    Xml,
57    Cloud,
58    /// The decoded response body as served, with no extraction — `web_fetch`
59    /// `raw`, for when readability guessed wrong about what matters.
60    Raw,
61}
62
63impl ExtractionMode {
64    #[must_use]
65    pub fn as_str(self) -> &'static str {
66        match self {
67            Self::Readability => "readability",
68            Self::HtmlToMarkdown => "html_to_markdown",
69            Self::PlainText => "plain_text",
70            Self::Markdown => "markdown",
71            Self::Json => "json",
72            Self::Xml => "xml",
73            Self::Cloud => "cloud",
74            Self::Raw => "raw",
75        }
76    }
77}
78
79/// Result from a web fetch, including transport and provenance facts that must
80/// survive into policy, UI, persistence, and model-visible formatting.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct WebFetchResult {
83    pub requested_url: String,
84    /// Final target URL when the backend can prove it. Cloud providers that do
85    /// not expose redirect provenance leave this unknown rather than claiming
86    /// the requested URL was final.
87    pub final_url: Option<String>,
88    /// Target response status. Ollama Cloud does not expose the target status.
89    pub status: Option<u16>,
90    pub media_type: Option<String>,
91    pub charset: Option<String>,
92    pub backend: FetchBackend,
93    pub extraction: ExtractionMode,
94    pub source_bytes: usize,
95    pub output_bytes: usize,
96    pub truncated: bool,
97    pub title: String,
98    pub content: String,
99}
100
101/// Stable transport/extraction failures retained across the provider boundary.
102/// The tool layer can project these into structured telemetry without parsing
103/// human-readable error strings.
104#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
105pub enum WebFetchError {
106    #[error("invalid web URL: {0}")]
107    InvalidUrl(String),
108    #[error("web destination denied: {0}")]
109    DestinationDenied(String),
110    #[error("web redirect denied: {0}")]
111    RedirectDenied(String),
112    #[error("HTTP {status} fetching {url}")]
113    HttpStatus { status: u16, url: String },
114    #[error("invalid response media type: {0}")]
115    InvalidMedia(String),
116    #[error("unsupported response media type '{0}'")]
117    UnsupportedMedia(String),
118    #[error("response body exceeded the {limit} byte per-request limit")]
119    BodyTooLarge { limit: usize },
120    #[error("response body exceeded the {limit} byte aggregate web budget for this turn")]
121    TurnBudgetExceeded { limit: usize },
122    #[error("unsupported response charset '{0}'")]
123    UnsupportedCharset(String),
124    #[error("response body is not valid {0} text")]
125    Decode(String),
126    #[error("web fetch returned no extractable content")]
127    EmptyContent,
128    #[error("web transport failed: {0}")]
129    Transport(String),
130    #[error("web extraction failed: {0}")]
131    Extraction(String),
132    #[error("web backend failed: {0}")]
133    Backend(String),
134}
135
136impl WebFetchError {
137    #[must_use]
138    pub fn kind(&self) -> &'static str {
139        match self {
140            Self::InvalidUrl(_) => "invalid_url",
141            Self::DestinationDenied(_) => "destination_denied",
142            Self::RedirectDenied(_) => "redirect_denied",
143            Self::HttpStatus { .. } => "http_status",
144            Self::InvalidMedia(_) => "invalid_media",
145            Self::UnsupportedMedia(_) => "unsupported_media",
146            Self::BodyTooLarge { .. } => "body_too_large",
147            Self::TurnBudgetExceeded { .. } => "turn_budget_exceeded",
148            Self::UnsupportedCharset(_) => "unsupported_charset",
149            Self::Decode(_) => "decode",
150            Self::EmptyContent => "empty_content",
151            Self::Transport(_) => "transport",
152            Self::Extraction(_) => "extraction",
153            Self::Backend(_) => "backend",
154        }
155    }
156
157    #[must_use]
158    pub fn status(&self) -> Option<u16> {
159        match self {
160            Self::HttpStatus { status, .. } => Some(*status),
161            _ => None,
162        }
163    }
164}
165
166type FetchResult<T> = std::result::Result<T, WebFetchError>;
167
168/// An HTTP(S) URL that has passed Mermaid's lexical web-destination policy.
169/// Fragments are removed because they are not part of an HTTP request and may
170/// contain sensitive client-side state.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct ValidatedWebUrl(reqwest::Url);
173
174impl ValidatedWebUrl {
175    /// Parse and vet `raw` against the lexical web-destination policy.
176    ///
177    /// # Errors
178    ///
179    /// [`WebFetchError::InvalidUrl`] for anything the policy refuses: a URL
180    /// that will not parse, a scheme other than `http`/`https`, embedded
181    /// userinfo credentials, a URL past 8192 bytes, and a blocked host. This
182    /// is lexical only — nothing is resolved or connected to, so passing here
183    /// is not permission to reach the destination.
184    pub fn parse(raw: &str) -> FetchResult<Self> {
185        let url = reqwest::Url::parse(raw)
186            .map_err(|error| WebFetchError::InvalidUrl(error.to_string()))?;
187        Self::from_url(url)
188    }
189
190    fn from_url(url: reqwest::Url) -> FetchResult<Self> {
191        Self::from_url_with_policy(url, is_blocked_web_host)
192    }
193
194    fn from_url_with_policy(
195        mut url: reqwest::Url,
196        is_blocked: fn(&str) -> bool,
197    ) -> FetchResult<Self> {
198        match url.scheme() {
199            "http" | "https" => {},
200            scheme => {
201                return Err(WebFetchError::InvalidUrl(format!(
202                    "unsupported scheme '{scheme}' (only http/https allowed)"
203                )));
204            },
205        }
206        if !url.username().is_empty() || url.password().is_some() {
207            return Err(WebFetchError::InvalidUrl(
208                "userinfo credentials are not allowed".to_string(),
209            ));
210        }
211        if url.as_str().len() > 8192 {
212            return Err(WebFetchError::InvalidUrl(
213                "URL exceeds the 8192 byte limit".to_string(),
214            ));
215        }
216        let host = url
217            .host_str()
218            .ok_or_else(|| WebFetchError::InvalidUrl("URL has no host".to_string()))?;
219        if is_blocked(host) {
220            return Err(WebFetchError::DestinationDenied(format!(
221                "non-public or metadata host '{host}'"
222            )));
223        }
224        url.set_fragment(None);
225        Ok(Self(url))
226    }
227
228    #[cfg(test)]
229    fn from_fixture_url(url: reqwest::Url) -> FetchResult<Self> {
230        fn fixture_blocked(host: &str) -> bool {
231            if classify_host(host).is_loopback() {
232                return false;
233            }
234            is_blocked_web_host(host)
235        }
236        Self::from_url_with_policy(url, fixture_blocked)
237    }
238
239    #[must_use]
240    pub fn as_url(&self) -> &reqwest::Url {
241        &self.0
242    }
243
244    #[must_use]
245    pub fn as_str(&self) -> &str {
246        self.0.as_str()
247    }
248}
249
250const METADATA_HOSTNAMES: &[&str] = &[
251    "metadata.google.internal",
252    "metadata.goog",
253    "metadata",
254    "instance-data",
255    "instance-data.ec2.internal",
256];
257
258fn is_blocked_web_host(host: &str) -> bool {
259    let normalized = host
260        .trim_start_matches('[')
261        .trim_end_matches(']')
262        .trim_end_matches('.')
263        .to_ascii_lowercase();
264    METADATA_HOSTNAMES.contains(&normalized.as_str()) || classify_host(host).is_internal()
265}
266
267/// A `web_search` backend: a query maps to ranked results. Implemented by
268/// [`OllamaWebClient`] (Ollama Cloud) and [`SearxngClient`] (self-hosted).
269#[async_trait]
270pub trait SearchProvider: Send + Sync {
271    async fn search(
272        &self,
273        query: &str,
274        count: usize,
275        budget: WebByteBudget,
276    ) -> Result<Vec<SearchResult>>;
277}
278
279/// A `web_fetch` backend: a URL maps to readable page content. Implemented by
280/// [`NativeFetchClient`] (in-process fetch) and [`OllamaWebClient`] (Ollama
281/// Cloud's server-side fetch).
282#[async_trait]
283pub trait FetchProvider: Send + Sync {
284    async fn fetch(&self, url: &str, budget: WebByteBudget) -> FetchResult<WebFetchResult>;
285
286    /// The page as served, without main-content extraction. Only a backend
287    /// that sees the response body can offer it; the rest say so.
288    async fn fetch_raw(&self, url: &str, budget: WebByteBudget) -> FetchResult<WebFetchResult> {
289        let _ = (url, budget);
290        Err(WebFetchError::Backend(
291            "this fetch backend returns only its own extraction; `raw` needs \
292             [web] fetch_backend = \"native\""
293                .to_string(),
294        ))
295    }
296}
297
298/// Ollama web search API response
299#[derive(Debug, Deserialize)]
300struct OllamaSearchResponse {
301    results: Vec<OllamaSearchResult>,
302}
303
304#[derive(Debug, Deserialize)]
305struct OllamaSearchResult {
306    title: String,
307    url: String,
308    content: String,
309}
310
311/// Ollama web fetch API response
312#[derive(Debug, Deserialize)]
313struct OllamaFetchResponse {
314    title: Option<String>,
315    content: Option<String>,
316}
317
318/// SearXNG JSON search response (`/search?format=json`). Only the fields we use
319/// are modelled; the rest of the (large) payload is ignored.
320#[derive(Debug, Deserialize)]
321struct SearxngResponse {
322    #[serde(default)]
323    results: Vec<SearxngResult>,
324}
325
326#[derive(Debug, Deserialize)]
327struct SearxngResult {
328    #[serde(default)]
329    title: String,
330    url: String,
331    #[serde(default)]
332    content: String,
333}
334
335const OLLAMA_API_BASE: &str = "https://ollama.com/api";
336
337/// User-Agent for native (`fetch_backend = "native"`) fetches. Some sites 403 a
338/// blank UA; a descriptive one is polite and identifies the client.
339const NATIVE_FETCH_UA: &str =
340    "Mozilla/5.0 (compatible; MermaidBot/1.0; +https://github.com/noahsabaj/mermaid-cli)";
341
342/// Carries the HTTP status of a non-success response so the retry classifier can
343/// tell retryable (5xx / 429) from terminal (4xx) responses without
344/// string-matching the error message (#85).
345#[derive(Debug)]
346pub struct HttpStatusError {
347    status: u16,
348}
349
350impl HttpStatusError {
351    #[must_use]
352    pub fn status(&self) -> u16 {
353        self.status
354    }
355}
356
357impl std::fmt::Display for HttpStatusError {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        write!(f, "HTTP {}", self.status)
360    }
361}
362
363impl std::error::Error for HttpStatusError {}
364
365/// Web client backed by Ollama Cloud's `/api/web_search` + `/api/web_fetch`,
366/// authenticated with a bearer token (`OLLAMA_API_KEY`).
367#[derive(Clone)]
368pub struct OllamaWebClient {
369    client: Client,
370    api_key: String,
371}
372
373impl OllamaWebClient {
374    /// Build the client. `api_key` is used as a bearer token per request.
375    ///
376    /// # Errors
377    ///
378    /// Only the HTTP client build, which is fail-closed: this client disables
379    /// redirects and the referer header, so a builder that cannot honor that
380    /// must not degrade into a permissive default. The key is not validated
381    /// here — that surfaces on the first request.
382    pub fn new(api_key: String) -> Result<Self> {
383        let client = Client::builder()
384            .redirect(reqwest::redirect::Policy::none())
385            .referer(false)
386            .build()
387            .map_err(|error| anyhow!("failed to build Ollama web client: {error}"))?;
388        Ok(Self { client, api_key })
389    }
390
391    /// Execute search via Ollama Cloud API.
392    ///
393    /// The `web_search` API already returns full page content per result, so no
394    /// separate `web_fetch` calls are needed. Each result's content is truncated
395    /// to prevent context bloat.
396    async fn search_impl(
397        &self,
398        query: &str,
399        count: usize,
400        budget: WebByteBudget,
401    ) -> Result<Vec<SearchResult>> {
402        if count == 0 || count > 10 {
403            return Err(anyhow!(
404                "Result count must be between 1 and 10, got {count}"
405            ));
406        }
407
408        let client = self.client.clone();
409        let api_key = self.api_key.clone();
410        let query_owned = query.to_string();
411        let budget = budget.clone();
412        // `count` is Copy (usize) — safe to capture by value across retries
413        // Only the send is retried. Acquiring the download permits, checking
414        // the status, reading the capped body, redacting it and parsing the
415        // JSON all sit OUTSIDE: re-acquiring permits per attempt was a
416        // resource pattern nobody designed, and retrying a parse failure three
417        // times is pure waste.
418        let endpoint = reqwest::Url::parse(&format!("{OLLAMA_API_BASE}/web_search"))
419            .map_err(|e| anyhow!("invalid Ollama web search endpoint: {e}"))?;
420        let download_permits = acquire_download_permits(&endpoint).await?;
421        let response = retry_transient_http(|| {
422            let client = client.clone();
423            let api_key = api_key.clone();
424            let query = query_owned.clone();
425            let endpoint = endpoint.clone();
426            async move {
427                client
428                    .post(endpoint)
429                    .header("Authorization", format!("Bearer {api_key}"))
430                    .json(&serde_json::json!({
431                        "query": query,
432                        "max_results": count,
433                    }))
434                    .timeout(Duration::from_secs(30))
435                    .send()
436                    .await
437                    // `retry_transient_http` speaks `ModelError`, and
438                    // `From<reqwest::Error>` already classifies timeout vs
439                    // connect vs status the way its retry decision needs.
440                    .map_err(mermaid_model::models::ModelError::from)
441            }
442        })
443        .await
444        .map_err(|e| anyhow!("Failed to reach Ollama web search API: {e}"))?;
445
446        if !response.status().is_success() {
447            let status = response.status();
448            let body = read_body_capped(
449                response,
450                mermaid_model::constants::MAX_WEB_BODY_BYTES.min(64 * 1024),
451                &budget,
452            )
453            .await
454            .map(|body| String::from_utf8_lossy(&body).into_owned())
455            .unwrap_or_else(|_| "<unavailable>".to_string());
456            let body = mermaid_model::utils::redact_secrets(&body);
457            return Err(anyhow::Error::new(HttpStatusError {
458                status: status.as_u16(),
459            })
460            .context(format!(
461                "Ollama web search API returned error {status}: {body}"
462            )));
463        }
464
465        let body = read_body_capped(
466            response,
467            mermaid_model::constants::MAX_WEB_BODY_BYTES,
468            &budget,
469        )
470        .await?;
471        drop(download_permits);
472        let ollama_response: OllamaSearchResponse = serde_json::from_slice(&body)
473            .map_err(|e| anyhow!("Failed to parse Ollama search response: {e}"))?;
474
475        let search_results = map_search_results(
476            ollama_response
477                .results
478                .into_iter()
479                .map(|r| (r.title, r.url, r.content)),
480            count,
481        );
482
483        // Empty is a valid outcome (no matches), not an error.
484        Ok(search_results)
485    }
486
487    /// Fetch a URL's content via Ollama's `web_fetch` API.
488    async fn fetch_impl(&self, url: &str, budget: WebByteBudget) -> FetchResult<WebFetchResult> {
489        let requested = ValidatedWebUrl::parse(url)?;
490
491        let client = self.client.clone();
492        let api_key = self.api_key.clone();
493        let url_owned = requested.as_str().to_string();
494        let budget = budget.clone();
495        // Only the send retries; permits, status, capped read and parse are
496        // hoisted out. Same reasoning as the search path above.
497        let safe_url = mermaid_model::utils::sanitize_url_for_display(&url_owned);
498        let endpoint = reqwest::Url::parse(&format!("{OLLAMA_API_BASE}/web_fetch"))
499            .map_err(|e| anyhow!("invalid Ollama web fetch endpoint: {e}"))
500            .map_err(|error| map_cloud_fetch_error(error, requested.as_str()))?;
501        let download_permits = acquire_download_permits(&endpoint)
502            .await
503            .map_err(|error| map_cloud_fetch_error(error, requested.as_str()))?;
504        let raw = retry_transient_http(|| {
505            let client = client.clone();
506            let api_key = api_key.clone();
507            let url = url_owned.clone();
508            let endpoint = endpoint.clone();
509            async move {
510                client
511                    .post(endpoint)
512                    .header("Authorization", format!("Bearer {api_key}"))
513                    .json(&serde_json::json!({ "url": url }))
514                    .timeout(Duration::from_secs(15))
515                    .send()
516                    .await
517                    .map_err(mermaid_model::models::ModelError::from)
518            }
519        })
520        .await
521        .map_err(|e| anyhow!("Failed to fetch {safe_url}: {e}"))
522        .map_err(|error| map_cloud_fetch_error(error, requested.as_str()))?;
523
524        if !raw.status().is_success() {
525            let status = raw.status();
526            let error = anyhow::Error::new(HttpStatusError {
527                status: status.as_u16(),
528            })
529            .context(format!("Failed to fetch {safe_url}: HTTP {status}"));
530            return Err(map_cloud_fetch_error(error, requested.as_str()));
531        }
532
533        let body =
534            read_body_capped(raw, mermaid_model::constants::MAX_WEB_BODY_BYTES, &budget).await?;
535        drop(download_permits);
536        let source_bytes = body.len();
537        let parsed: OllamaFetchResponse = serde_json::from_slice(&body)
538            .map_err(|e| anyhow!("Failed to parse fetch response: {e}"))
539            .map_err(|error| map_cloud_fetch_error(error, requested.as_str()))?;
540        let response = (parsed, source_bytes);
541
542        let source_bytes = response.1;
543        let title = response.0.title.unwrap_or_default();
544        let content = response.0.content.unwrap_or_default();
545        if content.trim().is_empty() {
546            return Err(WebFetchError::EmptyContent);
547        }
548        let output_bytes = content.len();
549        Ok(WebFetchResult {
550            requested_url: requested.as_str().to_string(),
551            // Ollama's API does not expose redirect provenance.
552            final_url: None,
553            status: None,
554            media_type: None,
555            charset: None,
556            backend: FetchBackend::OllamaCloud,
557            extraction: ExtractionMode::Cloud,
558            source_bytes,
559            output_bytes,
560            truncated: false,
561            title,
562            content,
563        })
564    }
565}
566
567#[async_trait]
568impl SearchProvider for OllamaWebClient {
569    async fn search(
570        &self,
571        query: &str,
572        count: usize,
573        budget: WebByteBudget,
574    ) -> Result<Vec<SearchResult>> {
575        self.search_impl(query, count, budget).await
576    }
577}
578
579#[async_trait]
580impl FetchProvider for OllamaWebClient {
581    async fn fetch(&self, url: &str, budget: WebByteBudget) -> FetchResult<WebFetchResult> {
582        self.fetch_impl(url, budget).await
583    }
584}
585
586fn map_cloud_fetch_error(error: anyhow::Error, requested_url: &str) -> WebFetchError {
587    if let Some(error) = error.downcast_ref::<WebFetchError>() {
588        return error.clone();
589    }
590    if let Some(status) = error.downcast_ref::<HttpStatusError>() {
591        return WebFetchError::HttpStatus {
592            status: status.status(),
593            url: mermaid_model::utils::sanitize_url_for_display(requested_url),
594        };
595    }
596    if let Some(error) = error.downcast_ref::<reqwest::Error>() {
597        return WebFetchError::Transport(mermaid_model::utils::redact_secrets(&error.to_string()));
598    }
599    WebFetchError::Backend(mermaid_model::utils::redact_secrets(&format!("{error:#}")))
600}
601
602/// `web_search` backed by a self-hosted SearXNG instance's JSON API. Keyless —
603/// the instance itself queries upstream engines; Mermaid only talks to the
604/// user's local SearXNG.
605#[derive(Clone)]
606pub struct SearxngClient {
607    client: Client,
608    base_url: String,
609}
610
611impl SearxngClient {
612    /// Build a client against the user's own SearXNG instance.
613    ///
614    /// # Errors
615    ///
616    /// A `base_url` that will not parse, uses a scheme other than
617    /// `http`/`https`, carries userinfo credentials, or carries a query — the
618    /// base is a prefix the search params are appended to, so a query here
619    /// would be silently dropped. Also the HTTP client build. The instance is
620    /// not contacted, so an `Ok` client does not mean SearXNG is running.
621    pub fn new(base_url: String) -> Result<Self> {
622        Self::build(base_url, false)
623    }
624
625    fn managed(base_url: String) -> Result<Self> {
626        Self::build(base_url, true)
627    }
628
629    fn build(base_url: String, managed_local: bool) -> Result<Self> {
630        let mut parsed = reqwest::Url::parse(&base_url)
631            .map_err(|error| anyhow!("invalid SearXNG URL: {error}"))?;
632        if !matches!(parsed.scheme(), "http" | "https") {
633            return Err(anyhow!("SearXNG URL must use http or https"));
634        }
635        if !parsed.username().is_empty() || parsed.password().is_some() {
636            return Err(anyhow!("SearXNG URL must not contain userinfo credentials"));
637        }
638        if parsed.query().is_some() {
639            return Err(anyhow!("SearXNG base URL must not contain a query"));
640        }
641        parsed.set_fragment(None);
642        let mut builder = Client::builder()
643            .redirect(reqwest::redirect::Policy::none())
644            .referer(false);
645        if managed_local {
646            // The owned localhost process must never be reached through an
647            // ambient corporate or attacker-controlled HTTP proxy.
648            builder = builder.no_proxy();
649        }
650        let client = builder
651            .build()
652            .map_err(|error| anyhow!("failed to build SearXNG client: {error}"))?;
653        Ok(Self {
654            client,
655            base_url: parsed.as_str().trim_end_matches('/').to_string(),
656        })
657    }
658}
659
660#[async_trait]
661impl SearchProvider for SearxngClient {
662    async fn search(
663        &self,
664        query: &str,
665        count: usize,
666        budget: WebByteBudget,
667    ) -> Result<Vec<SearchResult>> {
668        let safe_base = mermaid_model::utils::sanitize_url_for_display(&self.base_url);
669        let request_url = reqwest::Url::parse_with_params(
670            &format!("{}/search", self.base_url),
671            &[("q", query), ("format", "json")],
672        )
673        .map_err(|e| anyhow!("invalid SearXNG URL {safe_base}: {e}"))?;
674
675        let download_permits = acquire_download_permits(&request_url).await?;
676
677        let response = self
678            .client
679            .get(request_url)
680            .timeout(Duration::from_secs(30))
681            .send()
682            .await
683            .map_err(|e| {
684                anyhow::Error::new(e).context(format!(
685                    "Failed to reach SearXNG at {safe_base} — is it running?"
686                ))
687            })?;
688
689        if !response.status().is_success() {
690            let status = response.status();
691            return Err(anyhow!(
692                "SearXNG at {safe_base} returned {status}. A 403 usually means the JSON format is \
693                 disabled — add `json` to `search.formats` in its settings.yml."
694            ));
695        }
696
697        let body = read_body_capped(
698            response,
699            mermaid_model::constants::MAX_WEB_BODY_BYTES,
700            &budget,
701        )
702        .await?;
703        drop(download_permits);
704        let parsed: SearxngResponse = serde_json::from_slice(&body).map_err(|e| {
705            anyhow!("Failed to parse SearXNG response (is `format=json` enabled?): {e}")
706        })?;
707
708        let results = map_search_results(
709            parsed
710                .results
711                .into_iter()
712                .map(|r| (r.title, r.url, r.content)),
713            count,
714        );
715
716        // Empty is a valid outcome (no matches), not an error — the tool layer
717        // reports "no results" and keeps any sibling queries' results.
718        Ok(results)
719    }
720}
721
722/// `web_search` backed by Mermaid's auto-managed local SearXNG bundle
723/// (`crate::searxng`): starts the process lazily on the first search, reuses it,
724/// and tears it down on exit. This is the zero-config default on platforms for
725/// which Mermaid publishes a bundle.
726pub struct ManagedSearxngBackend;
727
728#[async_trait]
729impl SearchProvider for ManagedSearxngBackend {
730    async fn search(
731        &self,
732        query: &str,
733        count: usize,
734        budget: WebByteBudget,
735    ) -> Result<Vec<SearchResult>> {
736        let base_url = crate::searxng::manager().ensure_running().await?;
737        SearxngClient::managed(base_url)?
738            .search(query, count, budget)
739            .await
740    }
741}
742
743/// `web_fetch` performed in-process. Redirects are handled manually so every
744/// destination is authorized before its request is issued.
745#[derive(Clone)]
746pub struct NativeFetchClient {
747    client: Client,
748}
749
750impl NativeFetchClient {
751    /// Build the hardened native client. Construction is fail-closed: losing
752    /// the resolver, proxy policy, timeout, or redirect policy is an error.
753    ///
754    /// # Errors
755    ///
756    /// Only the HTTP client build — but deliberately not softened: the
757    /// vetting DNS resolver, the disabled proxy and redirect policies, and the
758    /// timeout are what confine every fetch, so a builder that cannot install
759    /// them must fail rather than hand back a client without them.
760    pub fn new() -> Result<Self> {
761        let client = native_client_builder()
762            .dns_resolver(std::sync::Arc::new(VettingResolver))
763            .build()
764            .map_err(|e| anyhow!("failed to build hardened native web client: {e}"))?;
765        Ok(Self { client })
766    }
767
768    async fn fetch_validated(
769        &self,
770        requested: ValidatedWebUrl,
771        raw: bool,
772        budget: WebByteBudget,
773    ) -> FetchResult<WebFetchResult> {
774        self.fetch_with_validator(
775            requested,
776            ValidatedWebUrl::from_url,
777            mermaid_model::constants::MAX_WEB_BODY_BYTES,
778            raw,
779            budget,
780        )
781        .await
782    }
783
784    async fn fetch_with_validator(
785        &self,
786        requested: ValidatedWebUrl,
787        validate: fn(reqwest::Url) -> FetchResult<ValidatedWebUrl>,
788        max_body_bytes: usize,
789        raw: bool,
790        budget: WebByteBudget,
791    ) -> FetchResult<WebFetchResult> {
792        let requested_url = requested.as_str().to_string();
793        let mut current = requested;
794
795        for redirect_count in 0..=MAX_REDIRECTS {
796            let download_permits = acquire_download_permits(current.as_url())
797                .await
798                .map_err(|error| WebFetchError::Transport(error.to_string()))?;
799            let response = self
800                .client
801                .get(current.as_url().clone())
802                .send()
803                .await
804                .map_err(|error| map_native_transport_error(error, current.as_str()))?;
805            let status = response.status();
806            if is_followable_redirect(status) {
807                if redirect_count == MAX_REDIRECTS {
808                    return Err(WebFetchError::RedirectDenied(format!(
809                        "exceeded the {MAX_REDIRECTS}-redirect limit"
810                    )));
811                }
812                let location = response
813                    .headers()
814                    .get(reqwest::header::LOCATION)
815                    .ok_or_else(|| {
816                        WebFetchError::RedirectDenied(format!(
817                            "redirect response {status} has no Location header"
818                        ))
819                    })?
820                    .to_str()
821                    .map_err(|_| {
822                        WebFetchError::RedirectDenied(
823                            "redirect Location header is not valid text".to_string(),
824                        )
825                    })?;
826                current = validated_redirect_destination(&current, location, validate)?;
827                continue;
828            }
829
830            if !status.is_success() {
831                return Err(WebFetchError::HttpStatus {
832                    status: status.as_u16(),
833                    url: mermaid_model::utils::sanitize_url_for_display(current.as_str()),
834                });
835            }
836
837            // Parse and authorize the media type before buffering the body.
838            let media = ResponseMedia::from_headers(response.headers())?;
839            let final_url = validate(response.url().clone())?;
840            let body = read_body_capped(response, max_body_bytes, &budget).await?;
841            drop(download_permits);
842
843            let source_bytes = body.len();
844            let extraction_permit = extraction_semaphore().acquire_owned().await.map_err(|_| {
845                WebFetchError::Extraction("web extraction limiter is closed".to_string())
846            })?;
847            let final_url_for_extract = final_url.as_str().to_string();
848            let media_for_extract = media.clone();
849            let (title, content, extraction, charset) = tokio::task::spawn_blocking(move || {
850                // Keep the permit inside the blocking task. Cancelling the
851                // async caller drops its JoinHandle but cannot stop an already
852                // running blocking parser; the task must remain accounted for.
853                let _extraction_permit = extraction_permit;
854                decode_and_extract(body, &final_url_for_extract, &media_for_extract, raw)
855            })
856            .await
857            .map_err(|error| {
858                WebFetchError::Extraction(format!("content extraction task failed: {error}"))
859            })??;
860
861            let output_bytes = content.len();
862            return Ok(WebFetchResult {
863                requested_url,
864                final_url: Some(final_url.as_str().to_string()),
865                status: Some(status.as_u16()),
866                media_type: media.media_type,
867                charset: Some(charset),
868                backend: FetchBackend::Native,
869                extraction,
870                source_bytes,
871                output_bytes,
872                truncated: false,
873                title,
874                content,
875            });
876        }
877
878        Err(WebFetchError::RedirectDenied(
879            "redirect state was exhausted".to_string(),
880        ))
881    }
882}
883
884fn native_client_builder() -> reqwest::ClientBuilder {
885    Client::builder()
886        .user_agent(NATIVE_FETCH_UA)
887        .timeout(Duration::from_secs(20))
888        .redirect(reqwest::redirect::Policy::none())
889        .referer(false)
890        // A proxy resolves the target outside this resolver and would make the
891        // proxy address, rather than the requested destination, the object being
892        // vetted. Native fetches never inherit HTTP(S)_PROXY / ALL_PROXY.
893        .no_proxy()
894}
895
896fn is_followable_redirect(status: reqwest::StatusCode) -> bool {
897    matches!(
898        status,
899        reqwest::StatusCode::MOVED_PERMANENTLY
900            | reqwest::StatusCode::FOUND
901            | reqwest::StatusCode::SEE_OTHER
902            | reqwest::StatusCode::TEMPORARY_REDIRECT
903            | reqwest::StatusCode::PERMANENT_REDIRECT
904    )
905}
906
907fn validated_redirect_destination(
908    current: &ValidatedWebUrl,
909    location: &str,
910    validate: fn(reqwest::Url) -> FetchResult<ValidatedWebUrl>,
911) -> FetchResult<ValidatedWebUrl> {
912    let next = current
913        .as_url()
914        .join(location)
915        .map_err(|error| WebFetchError::RedirectDenied(error.to_string()))?;
916    let next = validate(next)?;
917    if current.as_url().scheme() == "https" && next.as_url().scheme() != "https" {
918        return Err(WebFetchError::RedirectDenied(format!(
919            "HTTPS-to-HTTP redirect from {} to {}",
920            mermaid_model::utils::sanitize_url_for_display(current.as_str()),
921            mermaid_model::utils::sanitize_url_for_display(next.as_str())
922        )));
923    }
924    Ok(next)
925}
926
927#[async_trait]
928impl FetchProvider for NativeFetchClient {
929    async fn fetch(&self, url: &str, budget: WebByteBudget) -> FetchResult<WebFetchResult> {
930        self.fetch_validated(ValidatedWebUrl::parse(url)?, false, budget)
931            .await
932    }
933
934    async fn fetch_raw(&self, url: &str, budget: WebByteBudget) -> FetchResult<WebFetchResult> {
935        self.fetch_validated(ValidatedWebUrl::parse(url)?, true, budget)
936            .await
937    }
938}
939
940/// A reqwest DNS resolver that rejects the entire answer when any address is
941/// not globally routable. The request connects to the exact vetted answer, so
942/// a second DNS lookup cannot rebind the name after authorization.
943struct VettingResolver;
944
945#[derive(Debug, thiserror::Error)]
946#[error("{0}")]
947struct DestinationPolicyError(String);
948
949impl reqwest::dns::Resolve for VettingResolver {
950    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
951        Box::pin(async move {
952            let host = name.as_str().to_string();
953            // Port 0 is a placeholder — reqwest overrides it with the URL's port.
954            // We only need the resolved IPs in order to vet them.
955            let addrs: Vec<std::net::SocketAddr> =
956                tokio::net::lookup_host((host.as_str(), 0)).await?.collect();
957            vet_resolved_addresses(&host, &addrs)
958                .map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })?;
959            Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs)
960        })
961    }
962}
963
964fn vet_resolved_addresses(
965    host: &str,
966    addrs: &[std::net::SocketAddr],
967) -> std::result::Result<(), DestinationPolicyError> {
968    if addrs.is_empty() {
969        return Err(DestinationPolicyError(format!(
970            "'{host}' resolved to no addresses"
971        )));
972    }
973    if addrs
974        .iter()
975        .any(|addr| classify_host(&addr.ip().to_string()).is_internal())
976    {
977        return Err(DestinationPolicyError(format!(
978            "refusing to connect to '{host}' — its DNS answer contains a non-public address"
979        )));
980    }
981    Ok(())
982}
983
984fn map_native_transport_error(error: reqwest::Error, requested_url: &str) -> WebFetchError {
985    let mut source = std::error::Error::source(&error);
986    while let Some(current) = source {
987        if let Some(policy) = current.downcast_ref::<DestinationPolicyError>() {
988            return WebFetchError::DestinationDenied(policy.0.clone());
989        }
990        source = current.source();
991    }
992    WebFetchError::Transport(mermaid_model::utils::redact_secrets(&format!(
993        "{}: {error}",
994        mermaid_model::utils::sanitize_url_for_display(requested_url)
995    )))
996}
997
998struct DownloadPermits {
999    _global: tokio::sync::OwnedSemaphorePermit,
1000    _origin: tokio::sync::OwnedSemaphorePermit,
1001}
1002
1003async fn acquire_download_permits(url: &reqwest::Url) -> Result<DownloadPermits> {
1004    download_limiter().acquire(url).await
1005}
1006
1007struct DownloadLimiter {
1008    global: Arc<Semaphore>,
1009    origins: Mutex<HashMap<String, Weak<Semaphore>>>,
1010    per_origin: usize,
1011}
1012
1013impl DownloadLimiter {
1014    fn new(global: usize, per_origin: usize) -> Self {
1015        Self {
1016            global: Arc::new(Semaphore::new(global)),
1017            origins: Mutex::new(HashMap::new()),
1018            per_origin,
1019        }
1020    }
1021
1022    async fn acquire(&self, url: &reqwest::Url) -> Result<DownloadPermits> {
1023        let origin = self
1024            .origin_semaphore(url)
1025            .acquire_owned()
1026            .await
1027            .map_err(|_| anyhow!("web origin limiter is closed"))?;
1028        let global = self
1029            .global
1030            .clone()
1031            .acquire_owned()
1032            .await
1033            .map_err(|_| anyhow!("web download limiter is closed"))?;
1034        Ok(DownloadPermits {
1035            _global: global,
1036            _origin: origin,
1037        })
1038    }
1039
1040    fn origin_semaphore(&self, url: &reqwest::Url) -> Arc<Semaphore> {
1041        let key = format!(
1042            "{}://{}:{}",
1043            url.scheme(),
1044            url.host_str().unwrap_or_default().to_ascii_lowercase(),
1045            url.port_or_known_default().unwrap_or_default()
1046        );
1047        let mut semaphores = self
1048            .origins
1049            .lock()
1050            .unwrap_or_else(std::sync::PoisonError::into_inner);
1051        semaphores.retain(|_, semaphore| semaphore.strong_count() > 0);
1052        if let Some(semaphore) = semaphores.get(&key).and_then(Weak::upgrade) {
1053            return semaphore;
1054        }
1055        let semaphore = Arc::new(Semaphore::new(self.per_origin));
1056        semaphores.insert(key, Arc::downgrade(&semaphore));
1057        semaphore
1058    }
1059}
1060
1061fn download_limiter() -> &'static DownloadLimiter {
1062    DOWNLOAD_LIMITER.get_or_init(|| {
1063        DownloadLimiter::new(
1064            mermaid_model::constants::MAX_WEB_DOWNLOAD_CONCURRENCY,
1065            mermaid_model::constants::MAX_WEB_PER_ORIGIN_CONCURRENCY,
1066        )
1067    })
1068}
1069
1070pub(super) fn extraction_semaphore() -> Arc<Semaphore> {
1071    EXTRACTION_SEMAPHORE
1072        .get_or_init(|| {
1073            Arc::new(Semaphore::new(
1074                mermaid_model::constants::MAX_WEB_EXTRACTION_CONCURRENCY,
1075            ))
1076        })
1077        .clone()
1078}
1079
1080#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1081enum MediaKind {
1082    Html,
1083    Xhtml,
1084    PlainText,
1085    Markdown,
1086    Json,
1087    Xml,
1088}
1089
1090#[derive(Debug, Clone)]
1091struct ResponseMedia {
1092    media_type: Option<String>,
1093    charset: Option<String>,
1094    kind: MediaKind,
1095}
1096
1097impl ResponseMedia {
1098    fn from_headers(headers: &reqwest::header::HeaderMap) -> FetchResult<Self> {
1099        let Some(raw) = headers.get(reqwest::header::CONTENT_TYPE) else {
1100            return Ok(Self {
1101                media_type: None,
1102                charset: None,
1103                kind: MediaKind::PlainText,
1104            });
1105        };
1106        let raw = raw.to_str().map_err(|_| {
1107            WebFetchError::InvalidMedia("Content-Type header is not valid text".to_string())
1108        })?;
1109        let parsed: mime::Mime = raw.parse().map_err(|error| {
1110            WebFetchError::InvalidMedia(format!("Content-Type '{raw}': {error}"))
1111        })?;
1112        let media_type = parsed.essence_str().to_ascii_lowercase();
1113        let charset = parsed
1114            .get_param(mime::CHARSET)
1115            .map(|value| value.as_str().to_string());
1116        let kind = match media_type.as_str() {
1117            "text/html" => MediaKind::Html,
1118            "application/xhtml+xml" => MediaKind::Xhtml,
1119            "text/markdown" | "text/x-markdown" | "application/markdown" => MediaKind::Markdown,
1120            "application/json" | "text/json" => MediaKind::Json,
1121            "application/xml" | "text/xml" => MediaKind::Xml,
1122            _ if media_type.ends_with("+json") => MediaKind::Json,
1123            _ if media_type.ends_with("+xml") => MediaKind::Xml,
1124            _ if media_type.starts_with("text/") => MediaKind::PlainText,
1125            _ => {
1126                return Err(WebFetchError::UnsupportedMedia(media_type));
1127            },
1128        };
1129        Ok(Self {
1130            media_type: Some(media_type),
1131            charset,
1132            kind,
1133        })
1134    }
1135}
1136
1137fn decode_and_extract(
1138    body: Vec<u8>,
1139    final_url: &str,
1140    media: &ResponseMedia,
1141    raw: bool,
1142) -> FetchResult<(String, String, ExtractionMode, String)> {
1143    if media.media_type.is_none() && body.contains(&0) {
1144        return Err(WebFetchError::UnsupportedMedia(
1145            "unlabeled binary data".to_string(),
1146        ));
1147    }
1148    let (decoded, charset) = decode_body(&body, media)?;
1149    let (title, content, extraction) = match media.kind {
1150        // Every other kind is already returned as served; only markup has an
1151        // extraction step to skip.
1152        MediaKind::Html | MediaKind::Xhtml if raw => {
1153            (fallback_title(&decoded), decoded, ExtractionMode::Raw)
1154        },
1155        MediaKind::Html | MediaKind::Xhtml => extract_readable(&decoded, final_url)?,
1156        MediaKind::PlainText => (
1157            String::new(),
1158            decoded.trim().to_string(),
1159            ExtractionMode::PlainText,
1160        ),
1161        MediaKind::Markdown => (
1162            String::new(),
1163            decoded.trim().to_string(),
1164            ExtractionMode::Markdown,
1165        ),
1166        MediaKind::Json => {
1167            let _: serde_json::Value = serde_json::from_str(&decoded).map_err(|error| {
1168                WebFetchError::Extraction(format!("invalid JSON response body: {error}"))
1169            })?;
1170            (String::new(), decoded, ExtractionMode::Json)
1171        },
1172        MediaKind::Xml => (
1173            String::new(),
1174            decoded.trim().to_string(),
1175            ExtractionMode::Xml,
1176        ),
1177    };
1178    if content.trim().is_empty() {
1179        return Err(WebFetchError::EmptyContent);
1180    }
1181    Ok((title, content, extraction, charset))
1182}
1183
1184fn decode_body(body: &[u8], media: &ResponseMedia) -> FetchResult<(String, String)> {
1185    // XML defines byte signatures for BOM-less UTF-16 and UTF-32. Check the
1186    // four-byte forms before encoding_rs's BOM helper: an UTF-32LE BOM starts
1187    // with the UTF-16LE BOM and would otherwise be misclassified.
1188    let signature_encoding = xml_signature_encoding(body, media.kind)?;
1189    let (encoding, bom_len) = if let Some(encoding) = signature_encoding {
1190        (encoding, 0)
1191    } else if let Some((encoding, bom_len)) = Encoding::for_bom(body) {
1192        (encoding, bom_len)
1193    } else {
1194        let declared = media.charset.as_deref().or_else(|| match media.kind {
1195            MediaKind::Html => find_ascii_assignment(body, b"charset"),
1196            MediaKind::Xhtml => find_ascii_assignment(body, b"charset")
1197                .or_else(|| find_ascii_assignment(body, b"encoding")),
1198            MediaKind::Xml => find_ascii_assignment(body, b"encoding"),
1199            _ => None,
1200        });
1201        let encoding = match declared {
1202            Some(label) => Encoding::for_label(label.as_bytes())
1203                .ok_or_else(|| WebFetchError::UnsupportedCharset(label.to_string()))?,
1204            None => UTF_8,
1205        };
1206        (encoding, 0)
1207    };
1208    let (decoded, had_errors) = encoding.decode_without_bom_handling(&body[bom_len..]);
1209    if had_errors {
1210        return Err(WebFetchError::Decode(encoding.name().to_string()));
1211    }
1212    Ok((decoded.into_owned(), encoding.name().to_ascii_lowercase()))
1213}
1214
1215fn xml_signature_encoding(body: &[u8], kind: MediaKind) -> FetchResult<Option<&'static Encoding>> {
1216    let Some(prefix) = body.get(..4) else {
1217        return Ok(None);
1218    };
1219    // UTF-32 BOMs apply regardless of MIME and must be checked before the
1220    // UTF-16 BOM prefix they share.
1221    match prefix {
1222        [0x00, 0x00, 0xfe, 0xff] => {
1223            return Err(WebFetchError::UnsupportedCharset("utf-32be".to_string()));
1224        },
1225        [0xff, 0xfe, 0x00, 0x00] => {
1226            return Err(WebFetchError::UnsupportedCharset("utf-32le".to_string()));
1227        },
1228        _ => {},
1229    }
1230    if !matches!(kind, MediaKind::Xml | MediaKind::Xhtml) {
1231        return Ok(None);
1232    }
1233    match prefix {
1234        // UTF-32 is deliberately diagnosed rather than fed to UTF-8/UTF-16:
1235        // encoding_rs does not implement it.
1236        [0x00, 0x00, 0x00, 0x3c] => Err(WebFetchError::UnsupportedCharset("utf-32be".to_string())),
1237        [0x3c, 0x00, 0x00, 0x00] => Err(WebFetchError::UnsupportedCharset("utf-32le".to_string())),
1238        [0x00, 0x3c, 0x00, 0x3f] => Ok(Some(UTF_16BE)),
1239        [0x3c, 0x00, 0x3f, 0x00] => Ok(Some(UTF_16LE)),
1240        // XML's EBCDIC signature is recognizable but unsupported here.
1241        [0x4c, 0x6f, 0xa7, 0x94] => Err(WebFetchError::UnsupportedCharset("ebcdic".to_string())),
1242        _ => Ok(None),
1243    }
1244}
1245
1246fn find_ascii_assignment<'a>(body: &'a [u8], name: &[u8]) -> Option<&'a str> {
1247    let prefix = &body[..body.len().min(4096)];
1248    let lower: Vec<u8> = prefix.iter().map(u8::to_ascii_lowercase).collect();
1249    let mut search_from = 0;
1250    while search_from + name.len() <= lower.len() {
1251        let Some(relative_start) = lower[search_from..]
1252            .windows(name.len())
1253            .position(|window| window == name)
1254        else {
1255            break;
1256        };
1257        let start = search_from + relative_start + name.len();
1258        let mut cursor = start;
1259        while lower.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1260            cursor += 1;
1261        }
1262        if lower.get(cursor) != Some(&b'=') {
1263            search_from = start;
1264            continue;
1265        }
1266        cursor += 1;
1267        while lower.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1268            cursor += 1;
1269        }
1270        let quote = lower
1271            .get(cursor)
1272            .copied()
1273            .filter(|byte| matches!(byte, b'\'' | b'"'));
1274        if quote.is_some() {
1275            cursor += 1;
1276        }
1277        let end = lower[cursor..]
1278            .iter()
1279            .position(|byte| {
1280                quote.map_or_else(
1281                    || byte.is_ascii_whitespace() || matches!(byte, b';' | b'>' | b'/'),
1282                    |quote| *byte == quote,
1283                )
1284            })
1285            .map_or(lower.len(), |end| end + cursor);
1286        if end > cursor {
1287            return std::str::from_utf8(&prefix[cursor..end]).ok();
1288        }
1289        search_from = start;
1290    }
1291    None
1292}
1293
1294/// Extract a page's title + main content (as markdown) from raw HTML. Uses a
1295/// readability pass to drop nav/boilerplate, then converts the isolated content
1296/// to markdown (preserving links). Falls back to whole-document conversion when
1297/// readability can't isolate useful content.
1298fn extract_readable(html: &str, url: &str) -> FetchResult<(String, String, ExtractionMode)> {
1299    use dom_smoothie::Readability;
1300
1301    if let Ok(mut readability) = Readability::new(html, Some(url), None)
1302        && let Ok(article) = readability.parse()
1303    {
1304        let content_html = article.content.to_string();
1305        let markdown = htmd::convert(&content_html).map_err(|error| {
1306            WebFetchError::Extraction(format!(
1307                "failed to convert readable HTML to markdown: {error}"
1308            ))
1309        })?;
1310        let markdown = markdown.trim();
1311        // Readability can succeed yet strip a page down to nothing (SPA shells,
1312        // pages it misjudges). Only trust a non-empty extraction; otherwise
1313        // fall through to whole-document conversion.
1314        if !markdown.is_empty() {
1315            let title = if article.title.trim().is_empty() {
1316                fallback_title(html)
1317            } else {
1318                article.title
1319            };
1320            return Ok((title, markdown.to_string(), ExtractionMode::Readability));
1321        }
1322    }
1323
1324    let markdown = htmd::convert(html).map_err(|error| {
1325        WebFetchError::Extraction(format!("failed to convert HTML to markdown: {error}"))
1326    })?;
1327    let markdown = markdown.trim();
1328    if markdown.is_empty() {
1329        return Err(WebFetchError::EmptyContent);
1330    }
1331    Ok((
1332        fallback_title(html),
1333        markdown.to_string(),
1334        ExtractionMode::HtmlToMarkdown,
1335    ))
1336}
1337
1338/// Crude `<title>` scrape for the fallback path (readability normally supplies
1339/// the title; this only runs when it couldn't parse the document).
1340fn fallback_title(html: &str) -> String {
1341    let lower = html.to_ascii_lowercase();
1342    let Some(open) = lower.find("<title") else {
1343        return String::new();
1344    };
1345    let after_tag = match html[open..].find('>') {
1346        Some(gt) => &html[open + gt + 1..],
1347        None => return String::new(),
1348    };
1349    match after_tag.to_ascii_lowercase().find("</title>") {
1350        Some(end) => after_tag[..end].trim().to_string(),
1351        None => String::new(),
1352    }
1353}
1354
1355/// Map raw `(title, url, content)` search hits to [`SearchResult`], truncating
1356/// each hit's content to bound model context. Shared by every search backend.
1357fn map_search_results(
1358    hits: impl Iterator<Item = (String, String, String)>,
1359    count: usize,
1360) -> Vec<SearchResult> {
1361    hits.take(count)
1362        .map(|(title, url, content)| {
1363            let full_content =
1364                truncate_content(&content, mermaid_model::constants::WEB_CONTENT_MAX_CHARS);
1365            let snippet = content.chars().take(200).collect();
1366            SearchResult {
1367                title,
1368                url,
1369                snippet,
1370                full_content,
1371            }
1372        })
1373        .collect()
1374}
1375
1376/// Format search results for model consumption.
1377///
1378/// Pure data -- no behavioral instructions. Citation rules live in the system
1379/// prompt (src/prompts.rs), which is the SSOT for all model behavior.
1380#[must_use]
1381pub fn format_results(results: &[SearchResult]) -> String {
1382    let mut formatted = String::from("[SEARCH_RESULTS]\n");
1383
1384    for (i, result) in results.iter().enumerate() {
1385        let url = mermaid_model::utils::sanitize_url_for_display(&result.url);
1386        formatted.push_str(&format!(
1387            "[{}] Title: {}\nURL: {}\nContent:\n{}\n---\n",
1388            i + 1,
1389            result.title,
1390            url,
1391            result.full_content
1392        ));
1393    }
1394
1395    formatted.push_str("[/SEARCH_RESULTS]\n\n");
1396
1397    // Source list for citation (behavior governed by system prompt)
1398    formatted.push_str("Sources:\n");
1399    for (i, result) in results.iter().enumerate() {
1400        let url = mermaid_model::utils::sanitize_url_for_display(&result.url);
1401        formatted.push_str(&format!("{}. {} - {}\n", i + 1, result.title, url));
1402    }
1403
1404    formatted
1405}
1406
1407/// Read a reqwest response body, refusing to buffer more than `max_bytes`.
1408/// `Response::json`/`bytes` buffer the whole body unbounded; a compromised or
1409/// misconfigured endpoint could return a multi-gigabyte body and OOM the
1410/// (long-lived) process. We reject early on an oversized `Content-Length` and
1411/// also enforce the cap while streaming (a lying or absent header can't bypass
1412/// it) (#28).
1413async fn read_body_capped(
1414    response: reqwest::Response,
1415    max_bytes: usize,
1416    budget: &WebByteBudget,
1417) -> FetchResult<Vec<u8>> {
1418    use futures::StreamExt;
1419    if let Some(len) = response.content_length()
1420        && len > max_bytes as u64
1421    {
1422        return Err(WebFetchError::BodyTooLarge { limit: max_bytes });
1423    }
1424    if let Some(len) = response.content_length()
1425        && usize::try_from(len).is_ok_and(|len| len > budget.remaining())
1426    {
1427        return Err(WebFetchError::TurnBudgetExceeded {
1428            limit: mermaid_model::constants::MAX_WEB_TURN_BYTES,
1429        });
1430    }
1431    if budget.remaining() == 0 {
1432        return Err(WebFetchError::TurnBudgetExceeded {
1433            limit: mermaid_model::constants::MAX_WEB_TURN_BYTES,
1434        });
1435    }
1436    let mut stream = response.bytes_stream();
1437    let mut buf = Vec::new();
1438    while let Some(chunk) = stream.next().await {
1439        let chunk = chunk.map_err(|error| WebFetchError::Transport(error.to_string()))?;
1440        // Charge every decoded chunk once it has crossed the transport
1441        // boundary, including the chunk that makes a single response fail its
1442        // own cap. Repeated oversized responses must not evade the turn total.
1443        if budget.charge(chunk.len()).is_err() {
1444            return Err(WebFetchError::TurnBudgetExceeded {
1445                limit: mermaid_model::constants::MAX_WEB_TURN_BYTES,
1446            });
1447        }
1448        if chunk.len() > max_bytes.saturating_sub(buf.len()) {
1449            return Err(WebFetchError::BodyTooLarge { limit: max_bytes });
1450        }
1451        buf.extend_from_slice(&chunk);
1452    }
1453    Ok(buf)
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458    use super::*;
1459    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1460
1461    enum FixtureBodyMode {
1462        Fixed,
1463        Chunked,
1464        TruncatedChunked,
1465    }
1466
1467    struct FixtureResponse {
1468        status: &'static str,
1469        headers: Vec<(&'static str, String)>,
1470        body: Vec<u8>,
1471        body_mode: FixtureBodyMode,
1472    }
1473
1474    impl FixtureResponse {
1475        fn text(body: &str) -> Self {
1476            Self {
1477                status: "200 OK",
1478                headers: vec![("Content-Type", "text/plain; charset=utf-8".to_string())],
1479                body: body.as_bytes().to_vec(),
1480                body_mode: FixtureBodyMode::Fixed,
1481            }
1482        }
1483
1484        fn chunked_text(body: &str) -> Self {
1485            Self {
1486                status: "200 OK",
1487                headers: vec![("Content-Type", "text/plain; charset=utf-8".to_string())],
1488                body: body.as_bytes().to_vec(),
1489                body_mode: FixtureBodyMode::Chunked,
1490            }
1491        }
1492
1493        fn truncated_chunked() -> Self {
1494            Self {
1495                status: "200 OK",
1496                headers: vec![("Content-Type", "text/plain; charset=utf-8".to_string())],
1497                body: Vec::new(),
1498                body_mode: FixtureBodyMode::TruncatedChunked,
1499            }
1500        }
1501
1502        fn redirect(location: String) -> Self {
1503            Self {
1504                status: "302 Found",
1505                headers: vec![("Location", location)],
1506                body: Vec::new(),
1507                body_mode: FixtureBodyMode::Fixed,
1508            }
1509        }
1510
1511        fn status(status: &'static str) -> Self {
1512            Self {
1513                status,
1514                headers: Vec::new(),
1515                body: Vec::new(),
1516                body_mode: FixtureBodyMode::Fixed,
1517            }
1518        }
1519
1520        fn gzip(body: Vec<u8>) -> Self {
1521            Self {
1522                status: "200 OK",
1523                headers: vec![
1524                    ("Content-Type", "text/plain; charset=utf-8".to_string()),
1525                    ("Content-Encoding", "gzip".to_string()),
1526                ],
1527                body,
1528                body_mode: FixtureBodyMode::Fixed,
1529            }
1530        }
1531
1532        fn wire_bytes(self) -> Vec<u8> {
1533            let Self {
1534                status,
1535                headers,
1536                body,
1537                body_mode,
1538            } = self;
1539            let mut response = format!("HTTP/1.1 {status}\r\n");
1540            for (name, value) in headers {
1541                response.push_str(&format!("{name}: {value}\r\n"));
1542            }
1543            match body_mode {
1544                FixtureBodyMode::Fixed => {
1545                    response.push_str(&format!(
1546                        "Content-Length: {}\r\nConnection: close\r\n\r\n",
1547                        body.len()
1548                    ));
1549                    let mut bytes = response.into_bytes();
1550                    bytes.extend_from_slice(&body);
1551                    bytes
1552                },
1553                FixtureBodyMode::Chunked => {
1554                    response.push_str("Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n");
1555                    let mut bytes = response.into_bytes();
1556                    bytes.extend_from_slice(format!("{:x}\r\n", body.len()).as_bytes());
1557                    bytes.extend_from_slice(&body);
1558                    bytes.extend_from_slice(b"\r\n0\r\n\r\n");
1559                    bytes
1560                },
1561                FixtureBodyMode::TruncatedChunked => {
1562                    response.push_str("Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n");
1563                    response.into_bytes()
1564                },
1565            }
1566        }
1567    }
1568
1569    struct FixtureServer {
1570        base_url: String,
1571        task: tokio::task::JoinHandle<Vec<String>>,
1572    }
1573
1574    impl FixtureServer {
1575        async fn spawn(responses: Vec<FixtureResponse>) -> Self {
1576            let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1577                .await
1578                .expect("bind fixture listener");
1579            let address = listener.local_addr().expect("fixture address");
1580            let task = tokio::spawn(async move {
1581                let mut requests = Vec::with_capacity(responses.len());
1582                for response in responses {
1583                    let (mut stream, _) =
1584                        tokio::time::timeout(Duration::from_secs(5), listener.accept())
1585                            .await
1586                            .expect("fixture request timed out")
1587                            .expect("accept fixture request");
1588                    requests.push(read_request_headers(&mut stream).await);
1589                    stream
1590                        .write_all(&response.wire_bytes())
1591                        .await
1592                        .expect("write fixture response");
1593                    stream.shutdown().await.expect("close fixture response");
1594                }
1595                requests
1596            });
1597            Self {
1598                base_url: format!("http://{address}"),
1599                task,
1600            }
1601        }
1602
1603        async fn requests(self) -> Vec<String> {
1604            tokio::time::timeout(Duration::from_secs(5), self.task)
1605                .await
1606                .expect("fixture server did not finish")
1607                .expect("fixture server task panicked")
1608        }
1609    }
1610
1611    async fn read_request_headers(stream: &mut tokio::net::TcpStream) -> String {
1612        let mut request = Vec::new();
1613        loop {
1614            let mut chunk = [0_u8; 1024];
1615            let read = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut chunk))
1616                .await
1617                .expect("request read timed out")
1618                .expect("read fixture request");
1619            if read == 0 {
1620                break;
1621            }
1622            request.extend_from_slice(&chunk[..read]);
1623            if request.windows(4).any(|window| window == b"\r\n\r\n") {
1624                break;
1625            }
1626            assert!(
1627                request.len() <= 64 * 1024,
1628                "fixture request headers too large"
1629            );
1630        }
1631        String::from_utf8(request).expect("HTTP request headers are UTF-8")
1632    }
1633
1634    fn fixture_client() -> NativeFetchClient {
1635        NativeFetchClient {
1636            client: native_client_builder()
1637                .build()
1638                .expect("build fixture client"),
1639        }
1640    }
1641
1642    async fn fixture_fetch(
1643        client: &NativeFetchClient,
1644        url: &str,
1645        max_body_bytes: usize,
1646    ) -> Result<WebFetchResult> {
1647        fixture_fetch_with_budget(client, url, max_body_bytes, WebByteBudget::isolated()).await
1648    }
1649
1650    async fn fixture_fetch_with_budget(
1651        client: &NativeFetchClient,
1652        url: &str,
1653        max_body_bytes: usize,
1654        budget: WebByteBudget,
1655    ) -> Result<WebFetchResult> {
1656        let requested = ValidatedWebUrl::from_fixture_url(
1657            reqwest::Url::parse(url).expect("valid fixture URL"),
1658        )?;
1659        Ok(client
1660            .fetch_with_validator(
1661                requested,
1662                ValidatedWebUrl::from_fixture_url,
1663                max_body_bytes,
1664                false,
1665                budget,
1666            )
1667            .await?)
1668    }
1669
1670    #[test]
1671    fn test_ollama_web_client_creation() {
1672        let client = OllamaWebClient::new("test-key".to_string()).unwrap();
1673        assert_eq!(client.api_key, "test-key");
1674    }
1675
1676    #[test]
1677    fn native_client_builds_fail_closed_configuration() {
1678        NativeFetchClient::new().expect("hardened client should build");
1679    }
1680
1681    #[test]
1682    fn configured_searxng_client_validates_its_trust_destination() {
1683        let client = SearxngClient::new("http://127.0.0.1:8080/base/#fragment".to_string())
1684            .expect("explicit self-hosted loopback is valid");
1685        assert_eq!(client.base_url, "http://127.0.0.1:8080/base");
1686        for invalid in [
1687            "file:///tmp/searxng",
1688            "https://user:password@example.test",
1689            "https://example.test/?token=secret",
1690            "not a URL",
1691        ] {
1692            assert!(
1693                SearxngClient::new(invalid.to_string()).is_err(),
1694                "accepted {invalid}"
1695            );
1696        }
1697    }
1698
1699    #[tokio::test]
1700    async fn configured_searxng_never_follows_query_bearing_redirects() {
1701        let target = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1702            .await
1703            .unwrap();
1704        let target_url = format!("http://{}", target.local_addr().unwrap());
1705        let target_seen = tokio::spawn(async move {
1706            tokio::time::timeout(Duration::from_millis(250), target.accept())
1707                .await
1708                .is_ok()
1709        });
1710        let source = FixtureServer::spawn(vec![FixtureResponse::redirect(target_url)]).await;
1711        let client = SearxngClient::new(source.base_url.clone()).unwrap();
1712
1713        let error = client
1714            .search("credential-shaped-query", 1, WebByteBudget::isolated())
1715            .await
1716            .unwrap_err();
1717
1718        assert!(format!("{error:#}").contains("302"));
1719        assert!(!target_seen.await.unwrap(), "redirect target was contacted");
1720        let requests = source.requests().await;
1721        assert!(requests[0].contains("credential-shaped-query"));
1722        assert!(!requests[0].to_ascii_lowercase().contains("referer:"));
1723    }
1724
1725    #[tokio::test]
1726    async fn native_fetch_follows_manual_redirect_and_records_final_url() {
1727        let server = FixtureServer::spawn(vec![
1728            FixtureResponse::redirect("/final".to_string()),
1729            FixtureResponse::text("redirected content"),
1730        ])
1731        .await;
1732        let base_url = server.base_url.clone();
1733        let page = fixture_fetch(
1734            &fixture_client(),
1735            &format!("{base_url}/start#client-fragment"),
1736            mermaid_model::constants::MAX_WEB_BODY_BYTES,
1737        )
1738        .await
1739        .unwrap();
1740
1741        assert_eq!(page.requested_url, format!("{base_url}/start"));
1742        assert_eq!(
1743            page.final_url.as_deref(),
1744            Some(format!("{base_url}/final").as_str())
1745        );
1746        assert_eq!(page.status, Some(200));
1747        assert_eq!(page.content, "redirected content");
1748        assert_eq!(page.backend, FetchBackend::Native);
1749        let requests = server.requests().await;
1750        assert!(requests[0].starts_with("GET /start HTTP/1.1"));
1751        assert!(requests[1].starts_with("GET /final HTTP/1.1"));
1752    }
1753
1754    #[tokio::test]
1755    async fn native_fetch_rejects_private_redirect_before_connecting() {
1756        let server = FixtureServer::spawn(vec![FixtureResponse::redirect(
1757            "http://169.254.169.254/latest/meta-data".to_string(),
1758        )])
1759        .await;
1760        let start_url = format!("{}/start", server.base_url);
1761        let error = fixture_fetch(
1762            &fixture_client(),
1763            &start_url,
1764            mermaid_model::constants::MAX_WEB_BODY_BYTES,
1765        )
1766        .await
1767        .unwrap_err();
1768
1769        assert!(
1770            format!("{error:#}").contains("non-public"),
1771            "unexpected redirect error: {error:#}"
1772        );
1773        assert_eq!(server.requests().await.len(), 1);
1774    }
1775
1776    #[tokio::test]
1777    async fn cross_origin_redirect_does_not_send_referer() {
1778        let destination = FixtureServer::spawn(vec![FixtureResponse::text("destination")]).await;
1779        let destination_url = destination.base_url.clone();
1780        let source = FixtureServer::spawn(vec![FixtureResponse::redirect(format!(
1781            "{destination_url}/final"
1782        ))])
1783        .await;
1784        let source_url = format!("{}/start?token=source-secret", source.base_url);
1785
1786        let page = fixture_fetch(
1787            &fixture_client(),
1788            &source_url,
1789            mermaid_model::constants::MAX_WEB_BODY_BYTES,
1790        )
1791        .await
1792        .unwrap();
1793        assert_eq!(
1794            page.final_url.as_deref(),
1795            Some(format!("{destination_url}/final").as_str())
1796        );
1797        source.requests().await;
1798        let destination_requests = destination.requests().await;
1799        assert!(
1800            !destination_requests[0]
1801                .to_ascii_lowercase()
1802                .contains("\r\nreferer:"),
1803            "cross-origin request leaked Referer: {}",
1804            destination_requests[0]
1805        );
1806    }
1807
1808    #[tokio::test]
1809    async fn native_client_ignores_ambient_proxy_configuration() {
1810        let target = FixtureServer::spawn(vec![FixtureResponse::text("direct")]).await;
1811        let target_url = format!("{}/resource", target.base_url);
1812        let proxy_listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1813            .await
1814            .expect("bind poison proxy");
1815        let proxy_url = format!(
1816            "http://{}",
1817            proxy_listener.local_addr().expect("proxy address")
1818        );
1819        let (proxy_seen_tx, proxy_seen_rx) = tokio::sync::oneshot::channel();
1820        let proxy_task = tokio::spawn(async move {
1821            let (mut stream, _) = proxy_listener.accept().await.expect("accept proxy request");
1822            let request = read_request_headers(&mut stream).await;
1823            let _ = proxy_seen_tx.send(request);
1824            stream
1825                .write_all(&FixtureResponse::status("502 Bad Gateway").wire_bytes())
1826                .await
1827                .expect("write proxy response");
1828        });
1829
1830        #[cfg(target_os = "windows")]
1831        let variables = vec![
1832            ("HTTP_PROXY", Some(proxy_url.as_str())),
1833            ("HTTPS_PROXY", Some(proxy_url.as_str())),
1834            ("ALL_PROXY", Some(proxy_url.as_str())),
1835            ("NO_PROXY", Some("")),
1836        ];
1837        #[cfg(not(target_os = "windows"))]
1838        let variables = vec![
1839            ("HTTP_PROXY", Some(proxy_url.as_str())),
1840            ("HTTPS_PROXY", Some(proxy_url.as_str())),
1841            ("ALL_PROXY", Some(proxy_url.as_str())),
1842            ("NO_PROXY", Some("")),
1843            ("http_proxy", Some(proxy_url.as_str())),
1844            ("https_proxy", Some(proxy_url.as_str())),
1845            ("all_proxy", Some(proxy_url.as_str())),
1846            ("no_proxy", Some("")),
1847        ];
1848
1849        let page = temp_env::async_with_vars(variables, async {
1850            let client = fixture_client();
1851            fixture_fetch(
1852                &client,
1853                &target_url,
1854                mermaid_model::constants::MAX_WEB_BODY_BYTES,
1855            )
1856            .await
1857        })
1858        .await
1859        .unwrap();
1860        assert_eq!(page.content, "direct");
1861        assert!(
1862            tokio::time::timeout(Duration::from_millis(150), proxy_seen_rx)
1863                .await
1864                .is_err(),
1865            "native fetch unexpectedly connected to the ambient proxy"
1866        );
1867        proxy_task.abort();
1868        target.requests().await;
1869    }
1870
1871    #[tokio::test]
1872    async fn native_fetch_preserves_typed_status_errors() {
1873        let server = FixtureServer::spawn(vec![FixtureResponse::status("404 Not Found")]).await;
1874        let url = format!("{}/missing", server.base_url);
1875        let error = fixture_fetch(
1876            &fixture_client(),
1877            &url,
1878            mermaid_model::constants::MAX_WEB_BODY_BYTES,
1879        )
1880        .await
1881        .unwrap_err();
1882
1883        assert!(matches!(
1884            error.downcast_ref::<WebFetchError>(),
1885            Some(WebFetchError::HttpStatus { status: 404, .. })
1886        ));
1887        assert!(format!("{error:#}").contains("HTTP 404"));
1888        server.requests().await;
1889    }
1890
1891    #[tokio::test]
1892    async fn decoded_gzip_body_cannot_bypass_streaming_limit() {
1893        use base64::Engine as _;
1894
1895        // 4096 ASCII 'a' bytes compressed to a small deterministic gzip member.
1896        let compressed = base64::engine::general_purpose::STANDARD
1897            .decode("H4sIAAAAAAAACu3BAQ0AAADCoKzvX8IeDigAAACgcwNz3JmcABAAAA==")
1898            .unwrap();
1899        assert!(compressed.len() < 1024);
1900        let server = FixtureServer::spawn(vec![FixtureResponse::gzip(compressed)]).await;
1901        let url = format!("{}/compressed", server.base_url);
1902        let budget = WebByteBudget::isolated();
1903        let error = fixture_fetch_with_budget(&fixture_client(), &url, 1024, budget.clone())
1904            .await
1905            .unwrap_err();
1906
1907        assert!(
1908            matches!(
1909                error.downcast_ref::<WebFetchError>(),
1910                Some(WebFetchError::BodyTooLarge { limit: 1024 })
1911            ),
1912            "decoded body limit was not enforced: {error:#}"
1913        );
1914        assert!(
1915            mermaid_model::constants::MAX_WEB_TURN_BYTES - budget.remaining() > 1024,
1916            "the decoded overflow chunk was not charged to the turn budget"
1917        );
1918        server.requests().await;
1919    }
1920
1921    #[tokio::test]
1922    async fn decoded_chunks_charge_the_shared_turn_budget_before_buffering() {
1923        let server = FixtureServer::spawn(vec![FixtureResponse::text("two bytes")]).await;
1924        let url = format!("{}/budget", server.base_url);
1925        let budget = WebByteBudget::isolated();
1926        budget
1927            .charge(mermaid_model::constants::MAX_WEB_TURN_BYTES - 1)
1928            .unwrap();
1929
1930        let error = fixture_fetch_with_budget(
1931            &fixture_client(),
1932            &url,
1933            mermaid_model::constants::MAX_WEB_BODY_BYTES,
1934            budget,
1935        )
1936        .await
1937        .unwrap_err();
1938
1939        assert!(matches!(
1940            error.downcast_ref::<WebFetchError>(),
1941            Some(WebFetchError::TurnBudgetExceeded { .. })
1942        ));
1943        server.requests().await;
1944    }
1945
1946    #[tokio::test]
1947    async fn turn_budget_overflow_prevents_polling_later_chunked_bodies() {
1948        let server = FixtureServer::spawn(vec![
1949            FixtureResponse::chunked_text("two bytes"),
1950            FixtureResponse::truncated_chunked(),
1951        ])
1952        .await;
1953        let url = format!("{}/budget", server.base_url);
1954        let budget = WebByteBudget::isolated();
1955        budget
1956            .charge(mermaid_model::constants::MAX_WEB_TURN_BYTES - 1)
1957            .unwrap();
1958
1959        let first = fixture_fetch_with_budget(
1960            &fixture_client(),
1961            &url,
1962            mermaid_model::constants::MAX_WEB_BODY_BYTES,
1963            budget.clone(),
1964        )
1965        .await
1966        .unwrap_err();
1967        assert!(matches!(
1968            first.downcast_ref::<WebFetchError>(),
1969            Some(WebFetchError::TurnBudgetExceeded { .. })
1970        ));
1971        assert_eq!(budget.remaining(), 0);
1972
1973        // The second response advertises chunked framing but closes before a
1974        // chunk arrives. A body poll would therefore produce a transport
1975        // error; the exhausted budget must win before the stream is polled.
1976        let second = fixture_fetch_with_budget(
1977            &fixture_client(),
1978            &url,
1979            mermaid_model::constants::MAX_WEB_BODY_BYTES,
1980            budget.clone(),
1981        )
1982        .await
1983        .unwrap_err();
1984        assert!(
1985            matches!(
1986                second.downcast_ref::<WebFetchError>(),
1987                Some(WebFetchError::TurnBudgetExceeded { .. })
1988            ),
1989            "later body was polled after budget exhaustion: {second:#}"
1990        );
1991        assert_eq!(budget.remaining(), 0);
1992        server.requests().await;
1993    }
1994
1995    #[test]
1996    fn validated_url_normalizes_and_rejects_unsafe_destinations() {
1997        let url = ValidatedWebUrl::parse("https://example.com/path?q=1#private-state").unwrap();
1998        assert_eq!(url.as_str(), "https://example.com/path?q=1");
1999
2000        for unsafe_url in [
2001            "file:///etc/passwd",
2002            "https://user:password@example.com/",
2003            "http://127.0.0.1/",
2004            "http://2130706433/",
2005            "http://0x7f000001/",
2006            "http://169.254.169.254/latest/meta-data/",
2007            "http://198.18.0.1/",
2008            "http://[::ffff:127.0.0.1]/",
2009            "http://metadata.google.internal/",
2010        ] {
2011            assert!(
2012                ValidatedWebUrl::parse(unsafe_url).is_err(),
2013                "{unsafe_url} must be rejected"
2014            );
2015        }
2016        assert!(matches!(
2017            ValidatedWebUrl::parse("http://127.0.0.1/"),
2018            Err(WebFetchError::DestinationDenied(_))
2019        ));
2020    }
2021
2022    #[test]
2023    fn redirect_destination_is_revalidated_and_cannot_downgrade() {
2024        let https = ValidatedWebUrl::parse("https://example.com/a/start").unwrap();
2025        let relative =
2026            validated_redirect_destination(&https, "../final#section", ValidatedWebUrl::from_url)
2027                .unwrap();
2028        assert_eq!(relative.as_str(), "https://example.com/final");
2029
2030        assert!(matches!(
2031            validated_redirect_destination(
2032                &https,
2033                "http://example.com/plaintext",
2034                ValidatedWebUrl::from_url,
2035            ),
2036            Err(WebFetchError::RedirectDenied(_))
2037        ));
2038        assert!(
2039            validated_redirect_destination(
2040                &https,
2041                "http://127.0.0.1/admin",
2042                ValidatedWebUrl::from_url,
2043            )
2044            .is_err()
2045        );
2046        assert!(
2047            validated_redirect_destination(
2048                &https,
2049                "http://169.254.169.254/latest",
2050                ValidatedWebUrl::from_url,
2051            )
2052            .is_err()
2053        );
2054
2055        let http = ValidatedWebUrl::parse("http://example.com/start").unwrap();
2056        assert!(
2057            validated_redirect_destination(
2058                &http,
2059                "https://example.com/final",
2060                ValidatedWebUrl::from_url,
2061            )
2062            .is_ok()
2063        );
2064        assert!(is_followable_redirect(reqwest::StatusCode::FOUND));
2065        assert!(!is_followable_redirect(reqwest::StatusCode::NOT_MODIFIED));
2066    }
2067
2068    #[test]
2069    fn test_format_results() {
2070        let results = vec![SearchResult {
2071            title: "Test Article".to_string(),
2072            url: "https://example.com".to_string(),
2073            snippet: "This is a test".to_string(),
2074            full_content: "Full content here".to_string(),
2075        }];
2076
2077        let formatted = format_results(&results);
2078        assert!(formatted.contains("[SEARCH_RESULTS]"));
2079        assert!(formatted.contains("Test Article"));
2080        assert!(formatted.contains("https://example.com"));
2081        assert!(formatted.contains("[/SEARCH_RESULTS]"));
2082    }
2083
2084    #[test]
2085    fn format_results_sanitizes_urls_without_mutating_results() {
2086        let raw = "https://user:hunter2@example.com/path?token=opaque-secret&ok=yes#fragment";
2087        let results = vec![SearchResult {
2088            title: "Sensitive link".to_string(),
2089            url: raw.to_string(),
2090            snippet: String::new(),
2091            full_content: "content".to_string(),
2092        }];
2093
2094        let formatted = format_results(&results);
2095        assert_eq!(results[0].url, raw, "transport value must remain unchanged");
2096        for secret in ["user", "hunter2", "opaque-secret", "fragment"] {
2097            assert!(!formatted.contains(secret), "leaked {secret}: {formatted}");
2098        }
2099        assert!(formatted.contains("ok=yes"));
2100        assert!(formatted.contains("token=%5BREDACTED%5D"));
2101    }
2102
2103    #[test]
2104    fn map_search_results_truncates_and_caps_count() {
2105        let hits = (0..5).map(|i| {
2106            (
2107                format!("t{i}"),
2108                format!("https://e{i}.com"),
2109                "x".repeat(mermaid_model::constants::WEB_CONTENT_MAX_CHARS * 2),
2110            )
2111        });
2112        let out = map_search_results(hits, 3);
2113        assert_eq!(out.len(), 3, "count cap applied");
2114        assert!(
2115            out[0].full_content.len() <= mermaid_model::constants::WEB_CONTENT_MAX_CHARS + 64,
2116            "content truncated"
2117        );
2118        assert!(out[0].snippet.chars().count() <= 200);
2119    }
2120
2121    #[test]
2122    fn searxng_response_parses_results() {
2123        let json = serde_json::json!({
2124            "results": [
2125                {"title": "A", "url": "https://a.com", "content": "alpha"},
2126                {"url": "https://b.com"},
2127            ]
2128        })
2129        .to_string();
2130        let parsed: SearxngResponse = serde_json::from_str(&json).unwrap();
2131        assert_eq!(parsed.results.len(), 2);
2132        assert_eq!(parsed.results[0].url, "https://a.com");
2133        // Missing title/content default to empty, not a parse error.
2134        assert_eq!(parsed.results[1].title, "");
2135        assert_eq!(parsed.results[1].content, "");
2136    }
2137
2138    /// `raw` hands back the markup as served: nothing readability would drop
2139    /// (nav, scripts, attributes) is gone, and the title still comes along.
2140    #[test]
2141    fn raw_html_skips_extraction() {
2142        let html = response_media(Some("text/html; charset=utf-8")).unwrap();
2143        let page = "<html><head><title>Docs</title></head><body><nav>Menu</nav>\
2144                    <table data-version=\"2\"><tr><td>cell</td></tr></table></body></html>";
2145        let (title, content, mode, _) = decode_and_extract(
2146            page.as_bytes().to_vec(),
2147            "https://example.com/docs",
2148            &html,
2149            true,
2150        )
2151        .unwrap();
2152        assert_eq!(mode, ExtractionMode::Raw);
2153        assert_eq!(title, "Docs");
2154        assert_eq!(content, page);
2155        // Non-markup is served as-is either way.
2156        let plain = response_media(Some("text/plain")).unwrap();
2157        let (_, text, mode, _) =
2158            decode_and_extract(b"hello".to_vec(), "https://example.com", &plain, true).unwrap();
2159        assert_eq!((text.as_str(), mode), ("hello", ExtractionMode::PlainText));
2160    }
2161
2162    #[test]
2163    fn extract_readable_produces_markdown() {
2164        let html = r#"<html><head><title>My Page</title></head>
2165            <body><article><h1>Heading</h1><p>Hello <a href="https://x.com">link</a>.</p>
2166            <p>More text to satisfy the readability length heuristic so this block is
2167            treated as the main article content rather than boilerplate chrome.</p>
2168            </article></body></html>"#;
2169        let (title, md, mode) = extract_readable(html, "https://example.com/page").unwrap();
2170        assert!(!title.is_empty(), "title extracted, got {title:?}");
2171        assert!(matches!(
2172            mode,
2173            ExtractionMode::Readability | ExtractionMode::HtmlToMarkdown
2174        ));
2175        assert!(
2176            md.contains("Hello"),
2177            "content converted to markdown: {md:?}"
2178        );
2179        assert!(md.contains("](https://x.com)"), "links preserved: {md:?}");
2180    }
2181
2182    #[test]
2183    fn extract_readable_fallback_title_on_unparseable() {
2184        // A bare fragment with no article structure still yields a title + body.
2185        let html = "<title>Bare</title><p>just a snippet</p>";
2186        let (title, md, _) = extract_readable(html, "https://example.com").unwrap();
2187        assert_eq!(title, "Bare");
2188        assert!(md.contains("just a snippet"));
2189    }
2190
2191    #[test]
2192    fn html_extraction_uses_final_url_as_relative_link_base() {
2193        let html = r#"<html><body><article><h1>Page</h1>
2194            <p>This article has enough useful prose for extraction and a
2195            <a href="../source">relative source link</a> that must be resolved
2196            against the final response URL after redirects.</p></article></body></html>"#;
2197        let (_, markdown, _) =
2198            extract_readable(html, "https://example.com/redirected/page").unwrap();
2199        assert!(
2200            markdown.contains("https://example.com/source"),
2201            "relative link was not resolved against final URL: {markdown}"
2202        );
2203    }
2204
2205    fn response_media(raw: Option<&str>) -> FetchResult<ResponseMedia> {
2206        let mut headers = reqwest::header::HeaderMap::new();
2207        if let Some(raw) = raw {
2208            headers.insert(
2209                reqwest::header::CONTENT_TYPE,
2210                reqwest::header::HeaderValue::from_str(raw).unwrap(),
2211            );
2212        }
2213        ResponseMedia::from_headers(&headers)
2214    }
2215
2216    #[test]
2217    fn content_type_dispatch_is_exact_and_rejects_binary() {
2218        assert_eq!(
2219            response_media(Some("text/html; charset=utf-8"))
2220                .unwrap()
2221                .kind,
2222            MediaKind::Html
2223        );
2224        assert_eq!(
2225            response_media(Some("application/problem+json"))
2226                .unwrap()
2227                .kind,
2228            MediaKind::Json
2229        );
2230        assert_eq!(
2231            response_media(Some("application/atom+xml")).unwrap().kind,
2232            MediaKind::Xml
2233        );
2234        assert_eq!(
2235            response_media(Some("text/markdown")).unwrap().kind,
2236            MediaKind::Markdown
2237        );
2238        assert!(response_media(Some("application/octet-stream")).is_err());
2239        assert!(response_media(Some("text/html-ish")).is_ok());
2240        assert_eq!(
2241            response_media(Some("text/html-ish")).unwrap().kind,
2242            MediaKind::PlainText
2243        );
2244    }
2245
2246    #[test]
2247    fn declared_charset_is_decoded_without_lossy_replacement() {
2248        let media = response_media(Some("text/plain; charset=windows-1252")).unwrap();
2249        let (decoded, charset) = decode_body(b"caf\xe9", &media).unwrap();
2250        assert_eq!(decoded, "caf\u{e9}");
2251        assert_eq!(charset, "windows-1252");
2252
2253        let utf8 = response_media(Some("text/plain")).unwrap();
2254        assert!(decode_body(&[0xff], &utf8).is_err());
2255    }
2256
2257    #[test]
2258    fn html_meta_charset_is_honored_when_header_omits_it() {
2259        let media = response_media(Some("text/html")).unwrap();
2260        let body = b"<meta charset=\"windows-1252\"><p>caf\xe9</p>";
2261        let (decoded, charset) = decode_body(body, &media).unwrap();
2262        assert!(decoded.contains("caf\u{e9}"));
2263        assert_eq!(charset, "windows-1252");
2264    }
2265
2266    #[test]
2267    fn xhtml_xml_declaration_charset_is_honored() {
2268        let media = response_media(Some("application/xhtml+xml")).unwrap();
2269        assert_eq!(media.kind, MediaKind::Xhtml);
2270        let body = b"<?xml version=\"1.0\" encoding=\"windows-1252\"?><html><p>caf\xe9</p></html>";
2271        let (decoded, charset) = decode_body(body, &media).unwrap();
2272        assert!(decoded.contains("caf\u{e9}"));
2273        assert_eq!(charset, "windows-1252");
2274    }
2275
2276    #[test]
2277    fn bomless_xml_and_xhtml_utf16_signatures_are_decoded() {
2278        let source = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root>café</root>";
2279        for media_type in ["application/xml", "application/xhtml+xml"] {
2280            let media = response_media(Some(media_type)).unwrap();
2281            for (big_endian, expected_charset) in [(false, "utf-16le"), (true, "utf-16be")] {
2282                let body = source
2283                    .encode_utf16()
2284                    .flat_map(|unit| {
2285                        if big_endian {
2286                            unit.to_be_bytes()
2287                        } else {
2288                            unit.to_le_bytes()
2289                        }
2290                    })
2291                    .collect::<Vec<_>>();
2292                let (decoded, charset) = decode_body(&body, &media).unwrap();
2293                assert_eq!(decoded, source);
2294                assert_eq!(charset, expected_charset);
2295            }
2296        }
2297    }
2298
2299    #[test]
2300    fn xml_utf32_signatures_return_a_typed_unsupported_charset() {
2301        let media = response_media(Some("application/xml")).unwrap();
2302        for (body, expected) in [
2303            (vec![0x00, 0x00, 0x00, 0x3c], "utf-32be"),
2304            (vec![0x3c, 0x00, 0x00, 0x00], "utf-32le"),
2305            (vec![0x00, 0x00, 0xfe, 0xff], "utf-32be"),
2306            (vec![0xff, 0xfe, 0x00, 0x00], "utf-32le"),
2307        ] {
2308            assert_eq!(
2309                decode_body(&body, &media),
2310                Err(WebFetchError::UnsupportedCharset(expected.to_string()))
2311            );
2312        }
2313    }
2314
2315    #[test]
2316    fn json_and_xml_are_not_interpreted_as_html() {
2317        let json_media = response_media(Some("application/json")).unwrap();
2318        let json_source = " \n{\"markup\":\"<h1>literal</h1>\",\"items\":[1,2]}\t ";
2319        let (_, json, mode, _) = decode_and_extract(
2320            json_source.as_bytes().to_vec(),
2321            "https://example.com/data",
2322            &json_media,
2323            false,
2324        )
2325        .unwrap();
2326        assert_eq!(mode, ExtractionMode::Json);
2327        assert_eq!(json, json_source);
2328
2329        let invalid = decode_and_extract(
2330            br#"{"markup": "unterminated}"#.to_vec(),
2331            "https://example.com/data",
2332            &json_media,
2333            false,
2334        )
2335        .unwrap_err();
2336        assert!(matches!(invalid, WebFetchError::Extraction(_)));
2337
2338        let xml_media = response_media(Some("application/xml")).unwrap();
2339        let xml_source = "<root><script>literal text</script></root>";
2340        let (_, xml, mode, _) = decode_and_extract(
2341            xml_source.as_bytes().to_vec(),
2342            "https://example.com/data",
2343            &xml_media,
2344            false,
2345        )
2346        .unwrap();
2347        assert_eq!(mode, ExtractionMode::Xml);
2348        assert_eq!(xml, xml_source);
2349    }
2350
2351    #[test]
2352    fn empty_extractions_are_errors() {
2353        let plain = response_media(Some("text/plain")).unwrap();
2354        assert!(
2355            decode_and_extract(b"  \r\n".to_vec(), "https://example.com", &plain, false).is_err()
2356        );
2357        let html = response_media(Some("text/html")).unwrap();
2358        assert!(
2359            decode_and_extract(
2360                b"<html><head></head><body></body></html>".to_vec(),
2361                "https://example.com",
2362                &html,
2363                false,
2364            )
2365            .is_err()
2366        );
2367        let unlabeled = response_media(None).unwrap();
2368        assert!(
2369            decode_and_extract(
2370                b"GIF89a\0binary".to_vec(),
2371                "https://example.com/image",
2372                &unlabeled,
2373                false,
2374            )
2375            .is_err()
2376        );
2377    }
2378
2379    #[tokio::test]
2380    async fn vetting_resolver_rejects_a_name_resolving_to_loopback() {
2381        use reqwest::dns::Resolve;
2382        use std::str::FromStr;
2383        // `localhost` resolves (via the hosts file, no network) to 127.0.0.1/::1
2384        // — both internal, so the connect-time resolver must fail closed. This is
2385        // the half of the DNS-rebinding guard a pre-flight resolve can't cover,
2386        // because it runs on the address the connection actually uses.
2387        let name = reqwest::dns::Name::from_str("localhost").expect("valid host name");
2388        assert!(
2389            VettingResolver.resolve(name).await.is_err(),
2390            "a name resolving to a loopback address must be rejected"
2391        );
2392    }
2393
2394    #[test]
2395    fn resolved_address_vetting_rejects_empty_and_mixed_answers() {
2396        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
2397
2398        let public = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
2399        let second_public = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443);
2400        let loopback = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
2401
2402        assert!(vet_resolved_addresses("example.com", &[]).is_err());
2403        assert!(vet_resolved_addresses("example.com", &[public, loopback]).is_err());
2404        assert!(vet_resolved_addresses("example.com", &[loopback, public]).is_err());
2405        assert!(vet_resolved_addresses("example.com", &[public, second_public]).is_ok());
2406    }
2407
2408    #[tokio::test]
2409    async fn download_limiter_enforces_global_origin_and_cancellation_release() {
2410        let limiter = DownloadLimiter::new(
2411            mermaid_model::constants::MAX_WEB_DOWNLOAD_CONCURRENCY,
2412            mermaid_model::constants::MAX_WEB_PER_ORIGIN_CONCURRENCY,
2413        );
2414
2415        let mut global_permits = Vec::new();
2416        for index in 0..mermaid_model::constants::MAX_WEB_DOWNLOAD_CONCURRENCY {
2417            let url = reqwest::Url::parse(&format!("https://origin-{index}.example.test/"))
2418                .expect("test URL");
2419            global_permits.push(limiter.acquire(&url).await.expect("global permit"));
2420        }
2421        let ninth_url = reqwest::Url::parse("https://ninth.example.test/").unwrap();
2422        let mut ninth = Box::pin(limiter.acquire(&ninth_url));
2423        assert!(
2424            tokio::time::timeout(Duration::from_millis(50), &mut ninth)
2425                .await
2426                .is_err(),
2427            "a ninth global download was admitted"
2428        );
2429        drop(global_permits.pop());
2430        let ninth_permit = tokio::time::timeout(Duration::from_secs(1), &mut ninth)
2431            .await
2432            .expect("global waiter was not released")
2433            .expect("global permit");
2434        drop(ninth_permit);
2435        drop(global_permits);
2436
2437        let same_origin = reqwest::Url::parse("https://same.example.test/page").unwrap();
2438        let first = limiter.acquire(&same_origin).await.unwrap();
2439        let second = limiter.acquire(&same_origin).await.unwrap();
2440        let mut third = Box::pin(limiter.acquire(&same_origin));
2441        assert!(
2442            tokio::time::timeout(Duration::from_millis(50), &mut third)
2443                .await
2444                .is_err(),
2445            "a third same-origin download was admitted"
2446        );
2447        drop(first);
2448        let third = tokio::time::timeout(Duration::from_secs(1), &mut third)
2449            .await
2450            .expect("origin waiter was not released")
2451            .expect("origin permit");
2452        drop(third);
2453        drop(second);
2454
2455        let cancellation_limiter = Arc::new(DownloadLimiter::new(1, 1));
2456        let held_url = reqwest::Url::parse("https://cancel.example.test/").unwrap();
2457        let holder_limiter = cancellation_limiter.clone();
2458        let holder_url = held_url.clone();
2459        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
2460        let holder = tokio::spawn(async move {
2461            let _permit = holder_limiter.acquire(&holder_url).await.unwrap();
2462            let _ = ready_tx.send(());
2463            std::future::pending::<()>().await;
2464        });
2465        ready_rx.await.expect("holder acquired its permit");
2466        let mut waiter = Box::pin(cancellation_limiter.acquire(&held_url));
2467        assert!(
2468            tokio::time::timeout(Duration::from_millis(50), &mut waiter)
2469                .await
2470                .is_err()
2471        );
2472        holder.abort();
2473        let _ = holder.await;
2474        let released = tokio::time::timeout(Duration::from_secs(1), &mut waiter)
2475            .await
2476            .expect("cancelled holder leaked its permits")
2477            .expect("released permit");
2478        drop(released);
2479    }
2480}