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#[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#[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#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct WebFetchResult {
83 pub requested_url: String,
84 pub final_url: Option<String>,
88 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct ValidatedWebUrl(reqwest::Url);
173
174impl ValidatedWebUrl {
175 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#[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#[async_trait]
283pub trait FetchProvider: Send + Sync {
284 async fn fetch(&self, url: &str, budget: WebByteBudget) -> FetchResult<WebFetchResult>;
285
286 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#[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#[derive(Debug, Deserialize)]
313struct OllamaFetchResponse {
314 title: Option<String>,
315 content: Option<String>,
316}
317
318#[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
337const NATIVE_FETCH_UA: &str =
340 "Mozilla/5.0 (compatible; MermaidBot/1.0; +https://github.com/noahsabaj/mermaid-cli)";
341
342#[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#[derive(Clone)]
368pub struct OllamaWebClient {
369 client: Client,
370 api_key: String,
371}
372
373impl OllamaWebClient {
374 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 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 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 .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 Ok(search_results)
485 }
486
487 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 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 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#[derive(Clone)]
606pub struct SearxngClient {
607 client: Client,
608 base_url: String,
609}
610
611impl SearxngClient {
612 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 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 Ok(results)
719 }
720}
721
722pub 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#[derive(Clone)]
746pub struct NativeFetchClient {
747 client: Client,
748}
749
750impl NativeFetchClient {
751 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(¤t, 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 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 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 .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
940struct 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 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 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 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 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 [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 [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
1294fn 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 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
1338fn 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
1355fn 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#[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 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
1407async 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 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 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 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 assert_eq!(parsed.results[1].title, "");
2135 assert_eq!(parsed.results[1].content, "");
2136 }
2137
2138 #[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 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 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 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}