fn default_base() -> String {
match std::env::var("SEARXNG_URL") {
Ok(s) => {
let s = s.trim_end_matches('/').to_string();
if s.is_empty() {
"http://127.0.0.1:8888".to_string()
} else {
s
}
}
Err(_) => {
let u = crate::rdsh_config::load().search.searxng_url;
let u = u.trim().trim_end_matches('/').to_string();
if u.is_empty() {
"http://127.0.0.1:8888".to_string()
} else {
u
}
}
}
}
fn encode_query(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
out.push(b as char);
} else if b == b' ' {
out.push('+');
} else {
let _ = write!(out, "%{b:02X}");
}
}
out
}
fn split_base(base: &str, locale: crate::locale::Locale) -> anyhow::Result<(String, u16, String)> {
anyhow::ensure!(
!base.chars().any(|c| c.is_control() || c.is_whitespace())
&& !base.contains(['@', '?', '#']),
"{}",
crate::locale::native_message(locale, crate::locale::NativeMessage::InvalidSearxUrl)
);
let rest = base.strip_prefix("http://").ok_or_else(|| {
anyhow::anyhow!(
"{}: {base}",
crate::locale::native_message(locale, crate::locale::NativeMessage::OnlyHttpSearxUrl)
)
})?;
let (hostport, prefix) = match rest.find('/') {
Some(i) => (&rest[..i], rest[i..].to_string()),
None => (rest, String::new()),
};
let (host, port_text) = if hostport.starts_with('[') {
let end = hostport.find(']').ok_or_else(|| {
anyhow::anyhow!(
"{}",
crate::locale::native_message(
locale,
crate::locale::NativeMessage::InvalidIpv6SearxHost
)
)
})?;
hostport[1..end]
.parse::<std::net::Ipv6Addr>()
.map_err(|_| {
anyhow::anyhow!(
"{}",
crate::locale::native_message(
locale,
crate::locale::NativeMessage::InvalidIpv6SearxHost
)
)
})?;
(&hostport[..=end], &hostport[end + 1..])
} else {
let end = hostport.find(':').unwrap_or(hostport.len());
let host = &hostport[..end];
anyhow::ensure!(
!host.is_empty()
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b)),
"{}",
crate::locale::native_message(locale, crate::locale::NativeMessage::InvalidSearxHost)
);
(host, &hostport[end..])
};
let port = if port_text.is_empty() {
80
} else {
port_text
.strip_prefix(':')
.and_then(|s| s.parse::<u16>().ok())
.filter(|port| *port != 0)
.ok_or_else(|| {
anyhow::anyhow!(
"{}",
crate::locale::native_message(
locale,
crate::locale::NativeMessage::InvalidSearxPort
)
)
})?
};
Ok((host.to_string(), port, prefix))
}
fn is_loopback_host(host: &str) -> bool {
let h = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
h == "localhost"
|| h.parse::<std::net::Ipv4Addr>()
.is_ok_and(|ip| ip.is_loopback())
|| h.parse::<std::net::Ipv6Addr>()
.is_ok_and(|ip| ip.is_loopback())
}
#[derive(Debug, Clone, PartialEq)]
struct Hit {
title: String,
url: String,
content: String,
}
fn strip_tags(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut tag = false;
let mut prev_space = true;
for c in s.chars() {
match c {
'<' => tag = true,
'>' => {
tag = false;
prev_space = true;
}
_ if tag => {}
_ if c.is_whitespace() => {
if !prev_space {
out.push(' ');
prev_space = true;
}
}
_ => {
out.push(c);
prev_space = false;
}
}
}
out.trim().to_string()
}
fn decode_entities(s: &str) -> String {
let mut out = s
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'");
let mut search = 0;
while let Some(offset) = out[search..].find("&#") {
let i = search + offset;
let rest = &out[i + 2..];
let Some(end) = rest.find(';') else {
break;
};
let num = &rest[..end];
let ch = if num.len() > 8 || num.contains('&') {
None
} else if let Some(hex) = num.strip_prefix(['x', 'X']) {
u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
} else {
num.parse::<u32>().ok().and_then(char::from_u32)
};
match ch {
Some(c) => {
out.replace_range(i..i + 2 + end + 1, &c.to_string());
search = i + c.len_utf8();
}
None => search = i + 2,
}
}
out
}
fn clean(s: &str) -> String {
decode_entities(&strip_tags(s))
}
fn parse_html(h: &str, limit: usize) -> Vec<Hit> {
let mut hits = Vec::new();
let mut rest = h;
while let Some(a) = rest.find("<article") {
let block = &rest[a..];
let end = block
.find("</article>")
.map(|i| i + 10)
.unwrap_or(block.len());
let blk = &block[..end];
rest = &block[end.min(block.len())..];
let head = &blk[..blk
.find('>')
.map(|i| i + 1)
.unwrap_or(blk.len())
.min(blk.len())];
let class = attr(head, "class").unwrap_or_default();
let tokens: Vec<&str> = class.split_whitespace().collect();
if !tokens.iter().any(|t| t.contains("result")) {
continue;
}
if tokens.iter().any(|t| t.contains("correction")) {
continue;
}
let href = match find_href(blk) {
Some(u) => u,
None => continue,
};
let title = match find_link_text(blk) {
Some(t) => clean(&t),
None => continue,
};
if title.is_empty() || href.is_empty() {
continue;
}
let content = find_snippet(blk).map(|s| clean(&s)).unwrap_or_default();
hits.push(Hit {
title,
url: href,
content,
});
if hits.len() >= limit.max(1) {
break;
}
}
hits
}
fn find_href(blk: &str) -> Option<String> {
let mut rest = blk;
while let Some(a) = rest.find("<a") {
let tag = &rest[a..];
let close = tag.find('>')?;
let head = &tag[..close];
if let Some(u) = attr(head, "href").filter(|u| !u.is_empty() && !u.starts_with('#')) {
return Some(decode_entities(&u));
}
rest = &tag[close + 1..];
}
None
}
fn find_link_text(blk: &str) -> Option<String> {
let mut rest = blk;
while let Some(a) = rest.find("<a") {
let tag = &rest[a..];
let close = tag.find('>')?;
let head = &tag[..close];
let ok = attr(head, "href").is_some_and(|u| !u.is_empty() && !u.starts_with('#'));
let after = &tag[close + 1..];
if ok {
let end = after.find("</a>").unwrap_or(after.len());
return Some(after[..end].to_string());
}
rest = after;
}
None
}
fn attr(tag_head: &str, name: &str) -> Option<String> {
const SQ: u8 = b'\'';
let mut rest = tag_head;
while let Some(i) = rest.find(name) {
let after = &rest[i + name.len()..];
let b = after.as_bytes();
if b.len() < 2 || b[0] != b'=' || (b[1] != b'"' && b[1] != SQ) {
let step = after.chars().next().map_or(0, char::len_utf8);
rest = &after[step..];
continue;
}
let v = &after[2..];
let end = v.find(b[1] as char).unwrap_or(v.len());
return Some(v[..end].to_string());
}
None
}
fn find_snippet(blk: &str) -> Option<String> {
let mut rest = blk;
while let Some(i) = rest.find("<p class=") {
let tag = &rest[i..];
let gt = tag.find('>')?;
if tag[..gt].contains("content") {
let after = &tag[gt + 1..];
let end = after.find("</p>").unwrap_or(after.len());
return Some(after[..end].to_string());
}
rest = &tag[gt + 1..];
}
None
}
fn fetch(base: &str, query: &str, locale: crate::locale::Locale) -> anyhow::Result<Vec<Hit>> {
use std::io::{Read, Write};
use std::net::ToSocketAddrs;
let (host, port, prefix) = split_base(base, locale)?;
if !is_loopback_host(&host) {
eprintln!("[rdsh] warning: SearXNG host {host} is not loopback; prefer an SSH tunnel to localhost");
}
let target = format!("{prefix}/search?q={}", encode_query(query));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
let mut stream = None;
for address in format!("{host}:{port}").to_socket_addrs()? {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
break;
}
if let Ok(connected) = std::net::TcpStream::connect_timeout(&address, remaining) {
stream = Some(connected);
break;
}
}
let mut s = stream.ok_or_else(|| match locale {
crate::locale::Locale::English => {
anyhow::anyhow!("cannot reach SearXNG at {base}; is it running? SEARXNG_URL overrides")
}
crate::locale::Locale::Chinese => {
anyhow::anyhow!("无法连接 {base} 的 SearXNG;它是否正在运行?SEARXNG_URL 会覆盖设置")
}
crate::locale::Locale::Japanese => {
anyhow::anyhow!("{base} の SearXNG に接続できません。起動していますか?SEARXNG_URL が設定を上書きします")
}
})?;
let ua = format!("rdsh/{}", env!("CARGO_PKG_VERSION"));
let host_header = if port == 80 {
host.clone()
} else {
format!("{host}:{port}")
};
let request = format!("GET {target} HTTP/1.0\r\nHost: {host_header}\r\nUser-Agent: {ua}\r\nAccept: text/html\r\nConnection: close\r\n\r\n");
let mut pending = request.as_bytes();
while !pending.is_empty() {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
anyhow::ensure!(
!remaining.is_zero(),
"{}",
crate::locale::native_message(
locale,
crate::locale::NativeMessage::SearxRequestTimedOut
)
);
s.set_write_timeout(Some(remaining))?;
let n = s.write(pending)?;
anyhow::ensure!(
n != 0,
"{}",
crate::locale::native_message(locale, crate::locale::NativeMessage::SearxRequestClosed)
);
pending = &pending[n..];
}
let mut raw = Vec::new();
let mut chunk = [0u8; 8192];
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
anyhow::bail!(
"{}",
crate::locale::native_message(
locale,
crate::locale::NativeMessage::SearxResponseTimedOut
)
);
}
s.set_read_timeout(Some(remaining))?;
let n = s.read(&mut chunk)?;
if n == 0 {
break;
}
if raw.len().saturating_add(n) > 2 * 1024 * 1024 {
anyhow::bail!(
"{}",
crate::locale::native_message(
locale,
crate::locale::NativeMessage::SearxResponseTooLarge
)
);
}
raw.extend_from_slice(&chunk[..n]);
}
let text = String::from_utf8_lossy(&raw);
let status = text
.lines()
.next()
.and_then(|line| {
let mut parts = line.split_whitespace();
let protocol = parts.next()?;
if !matches!(protocol, "HTTP/1.0" | "HTTP/1.1") {
return None;
}
parts.next()?.parse::<u16>().ok()
})
.ok_or_else(|| {
anyhow::anyhow!(
"{} {base}",
crate::locale::native_message(
locale,
crate::locale::NativeMessage::BadSearxResponse
)
)
})?;
if !(200..300).contains(&status) {
anyhow::bail!(
"{} {status} {} {base}",
crate::locale::native_message(locale, crate::locale::NativeMessage::SearxReturnedHttp),
match locale {
crate::locale::Locale::English => "from",
crate::locale::Locale::Chinese => ",来源",
crate::locale::Locale::Japanese => "、送信元",
}
);
}
let body = match text.find("\r\n\r\n") {
Some(i) => &text[i + 4..],
None => anyhow::bail!(
"{} {base}",
crate::locale::native_message(locale, crate::locale::NativeMessage::BadSearxResponse)
),
};
ensure_results_page(body, base, locale)?;
Ok(parse_html(body, 50))
}
fn ensure_results_page(
body: &str,
base: &str,
locale: crate::locale::Locale,
) -> anyhow::Result<()> {
if body.contains("<article") || looks_like_searxng(body) {
return Ok(());
}
anyhow::bail!(
"{} {base}{}",
crate::locale::native_message(locale, crate::locale::NativeMessage::NoSearxResultsPage),
match locale {
crate::locale::Locale::English => " (is SEARXNG_URL right?)",
crate::locale::Locale::Chinese => "(SEARXNG_URL 是否正确?)",
crate::locale::Locale::Japanese => "(SEARXNG_URL は正しいですか?)",
}
);
}
fn looks_like_searxng(body: &str) -> bool {
body.contains("id=\"results\"") || body.contains("content=\"searxng")
}
pub fn cmd_search_web(
query: &str,
limit: usize,
json: bool,
locale: crate::locale::Locale,
) -> anyhow::Result<()> {
let base = default_base();
let hits = fetch(&base, query, locale)?;
let shown: Vec<&Hit> = hits.iter().take(limit.max(1)).collect();
if json {
println!(
"{}",
serde_json::json!({
"query": query,
"engine": base,
"results": shown
.iter()
.map(|r| {
serde_json::json!({
"title": r.title,
"url": r.url,
"content": r.content,
})
})
.collect::<Vec<_>>(),
})
);
return Ok(());
}
use std::io::Write as _;
let out = std::io::stdout();
let mut out = std::io::BufWriter::new(out.lock());
for r in &shown {
let _ = writeln!(out, "- {}\n {}", r.title, r.url);
if !r.content.is_empty() {
let snippet: String = r.content.chars().take(240).collect();
let _ = writeln!(out, " {snippet}");
}
}
let _ = out.flush();
match locale {
crate::locale::Locale::English => eprintln!("[rdsh] {} web hit(s) via {base}", shown.len()),
crate::locale::Locale::Chinese => {
eprintln!("[rdsh] {} 个网页匹配,来源 {base}", shown.len())
}
crate::locale::Locale::Japanese => {
eprintln!("[rdsh] {} 件のWeb一致、経由 {base}", shown.len())
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_searxng_page_is_not_a_configuration_error() {
let page =
"<html><body><div id=\"results\"><p>No results were found.</p></div></body></html>";
assert!(ensure_results_page(
page,
"http://127.0.0.1:8888",
crate::locale::Locale::English
)
.is_ok());
assert!(parse_html(page, 50).is_empty());
}
#[test]
fn searxng_generator_meta_is_accepted() {
let page = "<html><head><meta name=\"generator\" content=\"searxng/2026\"></head></html>";
assert!(ensure_results_page(
page,
"http://127.0.0.1:8888",
crate::locale::Locale::English
)
.is_ok());
}
#[test]
fn a_plain_page_is_still_reported_as_a_wrong_url() {
let page = "<html><body><h1>hello</h1></body></html>";
assert!(ensure_results_page(
page,
"http://127.0.0.1:8888",
crate::locale::Locale::English
)
.is_err());
}
#[test]
fn empty_results_and_invalid_pages_retain_the_requested_language() {
use crate::locale::Locale;
for (locale, expected) in [
(Locale::English, "no SearXNG results page"),
(Locale::Chinese, "以下地址没有 SearXNG 结果页"),
(Locale::Japanese, "SearXNG の結果ページがありません"),
] {
assert!(ensure_results_page(
"<div id=\"results\"></div>",
"http://127.0.0.1:8888",
locale
)
.is_ok());
let error = ensure_results_page(
"<html>wrong service</html>",
"http://127.0.0.1:8888",
locale,
)
.unwrap_err()
.to_string();
assert!(error.contains(expected), "{error}");
assert!(error.contains("http://127.0.0.1:8888"));
}
}
#[test]
fn query_encoding() {
assert_eq!(encode_query("a b+c"), "a+b%2Bc");
assert_eq!(encode_query("日本語"), "%E6%97%A5%E6%9C%AC%E8%AA%9E");
}
#[test]
fn base_splitting() {
assert_eq!(
split_base("http://127.0.0.1:8888", crate::locale::Locale::English).unwrap(),
("127.0.0.1".to_string(), 8888, String::new())
);
assert_eq!(
split_base("http://h/searx", crate::locale::Locale::English).unwrap(),
("h".to_string(), 80, "/searx".to_string())
);
assert!(split_base("https://h/", crate::locale::Locale::English).is_err());
assert_eq!(
split_base("http://[::1]/searx", crate::locale::Locale::English).unwrap(),
("[::1]".to_string(), 80, "/searx".to_string())
);
assert_eq!(
split_base(
"http://searx.example:8888/prefix",
crate::locale::Locale::English
)
.unwrap(),
("searx.example".to_string(), 8888, "/prefix".to_string())
);
}
#[test]
fn base_rejects_http_request_injection_and_malformed_authorities() {
for base in [
"http://127.0.0.1:8888/prefix\r\nInjected: yes",
"http://127.0.0.1:8888/path HTTP/1.0",
"http://localhost\t:8888",
"http://user@localhost:8888",
"http://localhost:8888/path?other=query",
"http://localhost:8888/#fragment",
"http://[::1]extra:8888",
"http://[bad]:8888",
"http://::1",
"http://localhost:0",
"http://localhost:65536",
"http://localhost:",
"http:///path",
] {
assert!(
split_base(base, crate::locale::Locale::English).is_err(),
"{base:?}"
);
}
assert_eq!(encode_query("\r\nInjected: yes"), "%0D%0AInjected%3A+yes");
}
#[test]
fn fetch_encodes_query_and_preserves_configured_prefix() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let address = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
std::time::Instant::now() < deadline,
"no fixture connection"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(e) => panic!("fixture accept: {e}"),
}
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
let mut byte = [0];
assert_eq!(stream.read(&mut byte).unwrap(), 1);
request.push(byte[0]);
assert!(request.len() < 8192);
}
let body = "<article class=\"result\"><a href=\"https://example.invalid/\">DUMMY_RESULT</a></article>";
write!(
stream,
"HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
.unwrap();
String::from_utf8(request).unwrap()
});
let hits = fetch(
&format!("http://{address}/prefix"),
"hello\r\nInjected: yes",
crate::locale::Locale::English,
);
let request = server.join().unwrap();
assert_eq!(hits.unwrap()[0].title, "DUMMY_RESULT");
assert!(request.starts_with("GET /prefix/search?q=hello%0D%0AInjected%3A+yes HTTP/1.0\r\n"));
assert!(!request.contains("\r\nInjected:"));
assert!(request.contains(&format!("\r\nHost: {address}\r\n")));
}
#[test]
fn loopback_classification() {
for good in ["127.0.0.1", "127.1.2.3", "localhost", "::1", "[::1]"] {
assert!(is_loopback_host(good), "{good:?}");
}
for bad in [
"192.168.1.1",
"10.0.0.1",
"example.com",
"",
"127.0.0.1:8888",
] {
assert!(!is_loopback_host(bad), "{bad:?}");
}
}
#[test]
fn html_parsing() {
let h = "<article class=\"result\"><h3><a href=\"https://e.com/x?a=1&b=2\">A <b>B</b></a></h3><p class=\"content\">one <two></p></article><article class=\"result result-correction\"><a href=\"https://e.com/y\">no</a></article>";
let hits = parse_html(h, 10);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "A B");
assert_eq!(hits[0].url, "https://e.com/x?a=1&b=2");
assert_eq!(hits[0].content, "one <two>");
}
#[test]
fn html_parsing_keeps_correction_words_in_title_url_snippet() {
let h = concat!(
"<article class=\"result\"><h3><a href=\"https://example.invalid/a\">",
"Quantum error correction</a></h3><p class=\"content\">Useful paper</p></article>",
"<article class=\"result\"><h3><a href=\"https://example.invalid/correction-notes\">",
"Quantum paper</a></h3><p class=\"content\">Useful paper</p></article>",
"<article class=\"result\"><h3><a href=\"https://example.invalid/b\">",
"Quantum paper</a></h3><p class=\"content\">About error correction</p></article>",
"<article class=\"result result-correction\"><h3><a href=\"https://example.invalid/d\">",
"Did you mean?</a></h3></article>",
);
let hits = parse_html(h, 10);
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].title, "Quantum error correction");
assert_eq!(hits[0].url, "https://example.invalid/a");
assert_eq!(hits[1].url, "https://example.invalid/correction-notes");
assert_eq!(hits[2].content, "About error correction");
}
#[test]
fn html_parsing_skips_utf8_attribute_name_candidates() {
for character in ["é", "→", "🦀"] {
let url = format!("https://example.invalid/{character}");
let title = format!("結果 {character}");
let html = format!(
"<article class=\"result\"><a title=\"href{character}\" href=\"{url}\">{title}</a></article>"
);
assert_eq!(
parse_html(&html, 1),
vec![Hit {
title,
url,
content: String::new(),
}],
"failed to skip a {character:?} candidate"
);
}
}
#[test]
fn attribute_name_at_end_does_not_panic() {
assert_eq!(attr("href", "href"), None);
}
#[test]
fn malformed_numeric_entity_does_not_panic_or_hide_later_entities() {
assert_eq!(decode_entities("AA"), "AA");
assert_eq!(decode_entities("AAB"), "AAB");
assert_eq!(decode_entities("bad &#xZ; then A"), "bad &#xZ; then A");
}
}