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