Skip to main content

wm_tools/expansion/
web.rs

1//! Web research tools — `web.fetch`, `web.search`, `web.search_and_read`,
2//! `web.deep_fetch`.
3//!
4//! Port of the v26 `web_research` handlers (web_fetch / web_search /
5//! web_search_and_read / deep_fetch) onto the v5 substrate:
6//!
7//! - `web.fetch` — fetch a URL, return clean text (no browser needed)
8//! - `web.deep_fetch` — full-content retrieval (up to 200K chars)
9//! - `web.search` — DuckDuckGo HTML search, no API key required
10//! - `web.search_and_read` — search + fetch top results in one call
11//!
12//! Safety (Gana::Chariot, Resource::Network):
13//! - Every URL (including each redirect hop) passes `is_url_safe` — SSRF
14//!   defense-in-depth on top of the MCP boundary check
15//! - Response bodies are bounded (`max_chars`), timeouts are bounded
16//! - No HTML parser dependency: a compact tag/entity stripper is used
17
18#![forbid(unsafe_code)]
19
20use async_trait::async_trait;
21
22use serde_json::{Value, json};
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25use wm_core::security::is_url_safe;
26use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
27
28const USER_AGENT: &str = "WhiteMagic/5.6 (local research agent)";
29const MAX_REDIRECTS: u32 = 5;
30
31/// Result of a bounded fetch.
32pub(crate) struct Fetched {
33    pub(crate) url: String,
34    pub(crate) title: String,
35    /// Plain-text content (tags stripped, entities decoded).
36    pub(crate) content: String,
37    /// Raw body bytes (UTF-8 lossy) — used by search parsers.
38    pub(crate) raw: String,
39    pub(crate) status_code: u16,
40    pub(crate) duration_ms: f64,
41    pub(crate) pages: u32,
42}
43
44/// Validate a URL for SSRF safety.
45fn safe_url(url: &str) -> Result<String, wm_core::CoreError> {
46    if !is_url_safe(url) {
47        return Err(wm_core::CoreError::InvalidArgs(format!(
48            "unsafe URL (SSRF guard): {url}"
49        )));
50    }
51    Ok(url.to_string())
52}
53
54/// GET with manual redirect following — every hop re-validated for SSRF,
55/// body bounded, per-hop timeout.
56pub(crate) fn fetch_bounded(
57    start_url: &str,
58    max_chars: usize,
59    timeout: Duration,
60) -> Result<Fetched, wm_core::CoreError> {
61    let started = Instant::now();
62    let mut current = start_url.to_string();
63    let mut pages = 1u32;
64
65    for _hop in 0..=MAX_REDIRECTS {
66        let agent = ureq::Agent::config_builder()
67            .timeout_global(Some(timeout))
68            .build()
69            .new_agent();
70        let response = agent
71            .get(&current)
72            .header("User-Agent", USER_AGENT)
73            .call()
74            .map_err(|e| wm_core::CoreError::Tool(format!("fetch {current}: {e}")))?;
75
76        let status = response.status().as_u16();
77        if (300..400).contains(&status) {
78            let location = response
79                .headers()
80                .get("location")
81                .and_then(|v| v.to_str().ok())
82                .ok_or_else(|| {
83                    wm_core::CoreError::Tool(format!(
84                        "redirect {status} without Location at {current}"
85                    ))
86                })?
87                .to_string();
88            let next = resolve_url(&current, &location);
89            safe_url(&next)?;
90            current = next;
91            pages += 1;
92            continue;
93        }
94        if !(200..300).contains(&status) {
95            return Err(wm_core::CoreError::Tool(format!(
96                "HTTP {status} from {current}"
97            )));
98        }
99
100        // Bounded read: Read::take truncates silently (ureq's .limit() errors
101        // on oversized bodies, which would surface as a fetch failure). Read a
102        // generous raw window (head markup can dwarf the actual content) and
103        // truncate the stripped text to max_chars below.
104        let raw_budget = (max_chars as u64)
105            .saturating_mul(8)
106            .clamp(64_000, 1_000_000);
107        let mut reader = response.into_body().into_reader();
108        let mut bytes = Vec::new();
109        std::io::Read::read_to_end(
110            &mut std::io::Read::take(&mut reader, raw_budget),
111            &mut bytes,
112        )
113        .map_err(|e| wm_core::CoreError::Tool(format!("read {current}: {e}")))?;
114        let html = String::from_utf8_lossy(&bytes).into_owned();
115        let title = extract_title(&html).unwrap_or_default();
116        let content = strip_html(&html);
117        let content: String = content.chars().take(max_chars).collect();
118        return Ok(Fetched {
119            url: current,
120            title,
121            content,
122            raw: html,
123            status_code: status,
124            duration_ms: started.elapsed().as_secs_f64() * 1000.0,
125            pages,
126        });
127    }
128
129    Err(wm_core::CoreError::Tool(format!(
130        "too many redirects ({MAX_REDIRECTS})"
131    )))
132}
133
134/// Resolve a possibly-relative redirect target against the current URL
135/// (RFC 3986 §5.3: relative references resolve against the current path's
136/// directory).
137#[must_use]
138pub fn resolve_url(base: &str, location: &str) -> String {
139    if location.starts_with("http://") || location.starts_with("https://") {
140        return location.to_string();
141    }
142    let (scheme, rest) = base
143        .split_once("://")
144        .map_or(("https", base), |(s, r)| (s, r));
145    if location.starts_with("//") {
146        return format!("{scheme}:{location}");
147    }
148    let slash = rest.find('/').unwrap_or(rest.len());
149    let (host, path) = rest.split_at(slash);
150    if location.starts_with('/') {
151        return format!("{scheme}://{host}{location}");
152    }
153    // relative: resolve against the directory of the current path
154    let dir: String = if path.is_empty() {
155        "/".to_string()
156    } else {
157        format!("{}/", path.rsplit_once('/').map_or("/", |(d, _)| d))
158    };
159    format!("{scheme}://{host}{dir}{location}")
160}
161
162/// Extract the first `<title>…</title>`.
163pub(crate) fn extract_title(html: &str) -> Option<String> {
164    let lower = html.to_ascii_lowercase();
165    let start = lower.find("<title")?;
166    let gt = lower[start..].find('>')? + start + 1;
167    let end = lower[gt..].find("</title")? + gt;
168    let raw = &html[gt.min(html.len())..end.min(html.len())];
169    let title = strip_html(raw);
170    let title = title.trim();
171    if title.is_empty() {
172        None
173    } else {
174        Some(title.to_string())
175    }
176}
177
178/// Strip HTML to plain text: drop script/style content, tags, and decode
179/// common entities. Compact and dependency-free.
180#[must_use]
181pub fn strip_html(html: &str) -> String {
182    let mut out = String::with_capacity(html.len() / 2);
183    let mut in_script = false;
184    let mut chars = html.chars();
185    while let Some(c) = chars.next() {
186        match c {
187            '<' => {
188                let mut tag = String::new();
189                for pc in chars.by_ref() {
190                    tag.push(pc);
191                    if pc == '>' {
192                        break;
193                    }
194                }
195                let lower = tag.to_ascii_lowercase();
196                let trimmed = lower.trim_matches(['<', '>', '/']);
197                let name = trimmed.split_whitespace().next().unwrap_or("");
198                if name == "script" || name == "style" {
199                    // The leading '<' was consumed by the outer match, so a
200                    // leading '/' marks the closing tag.
201                    in_script = !lower.starts_with('/');
202                } else if !in_script
203                    && !lower.starts_with("</")
204                    && matches!(
205                        name,
206                        "p" | "br" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "tr"
207                    )
208                    && !out.ends_with('\n')
209                {
210                    out.push('\n');
211                }
212            }
213            _ if in_script => {} // inside <script>/<style>: drop content
214            '&' => {
215                // decode entity — but only when properly terminated with
216                // ';' before any '<' (a tag must never be swallowed by the
217                // decoder). Lookahead on a cloned iterator; consume only on
218                // a confirmed entity.
219                let mut lookahead = chars.clone();
220                let mut entity = String::new();
221                let mut terminated = false;
222                for _ in 0..=12 {
223                    match lookahead.next() {
224                        Some(';') => {
225                            terminated = true;
226                            break;
227                        }
228                        Some('<') => break,
229                        Some(c) => entity.push(c),
230                        None => break,
231                    }
232                }
233                if terminated {
234                    if is_known_entity(&entity) {
235                        // consume exactly the lookahead chars plus the ';'
236                        for _ in entity.chars() {
237                            chars.next();
238                        }
239                        chars.next();
240                        out.push_str(&decode_entity(&entity));
241                    } else {
242                        // unknown entity — emit '&' literally and let the
243                        // rest re-scan (prevents nested-entity leakage)
244                        out.push('&');
245                    }
246                } else {
247                    // not an entity — emit '&' literally and let the next
248                    // iteration handle whatever followed
249                    out.push('&');
250                }
251            }
252            c => out.push(c),
253        }
254    }
255    // Collapse whitespace runs to single spaces (keep newlines).
256    let mut result = String::with_capacity(out.len());
257    let mut pending_newline = false;
258    let mut pending_space = false;
259    for c in out.chars() {
260        if c == '\n' {
261            pending_newline = true;
262            pending_space = false;
263        } else if c.is_whitespace() {
264            pending_space = true;
265        } else {
266            if pending_newline {
267                if !result.ends_with('\n') && !result.is_empty() {
268                    result.push('\n');
269                }
270                pending_newline = false;
271            } else if pending_space {
272                if !result.ends_with(' ') && !result.ends_with('\n') && !result.is_empty() {
273                    result.push(' ');
274                }
275                pending_space = false;
276            }
277            result.push(c);
278        }
279    }
280    result.trim().to_string()
281}
282
283/// Decode a single HTML entity (`amp;`, `#123;`, …). Unknown entities are
284/// returned as `&name;` (browser behavior).
285fn decode_entity(entity: &str) -> String {
286    let e = entity.trim_end_matches(';');
287    let out = match e {
288        "amp" => "&",
289        "lt" => "<",
290        "gt" => ">",
291        "quot" => "\"",
292        "apos" | "#39" => "'",
293        "nbsp" => " ",
294        _ => {
295            if let Some(num) = e.strip_prefix('#') {
296                let code = num.parse::<u32>().ok().or_else(|| {
297                    num.strip_prefix('x')
298                        .and_then(|h| u32::from_str_radix(h, 16).ok())
299                });
300                if let Some(code) = code {
301                    if let Some(ch) = char::from_u32(code) {
302                        return ch.to_string();
303                    }
304                }
305            }
306            return format!("&{e};");
307        }
308    };
309    out.to_string()
310}
311
312/// Whether `entity` (without the trailing `;`) is a known entity that
313/// [`decode_entity`] will actually decode.
314fn is_known_entity(entity: &str) -> bool {
315    let e = entity.trim_end_matches(';');
316    if matches!(e, "amp" | "lt" | "gt" | "quot" | "apos" | "#39" | "nbsp") {
317        return true;
318    }
319    if let Some(num) = e.strip_prefix('#') {
320        let code = num.parse::<u32>().ok().or_else(|| {
321            num.strip_prefix('x')
322                .and_then(|h| u32::from_str_radix(h, 16).ok())
323        });
324        if let Some(code) = code {
325            return char::from_u32(code).is_some();
326        }
327    }
328    false
329}
330
331/// Extract the real target from a DuckDuckGo redirect href
332/// (`//duckduckgo.com/l/?uddg=<url-encoded>`).
333#[must_use]
334pub fn ddg_target(href: &str) -> Option<String> {
335    let idx = href.find("uddg=")?;
336    let encoded = &href[idx + 5..];
337    let end = encoded.find('&').unwrap_or(encoded.len());
338    let bytes = encoded.as_bytes();
339    let end = end.min(bytes.len());
340    let mut out = Vec::new();
341    let mut i = 0;
342    while i < end {
343        if bytes[i] == b'%' && i + 2 < end {
344            if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
345                out.push((hi << 4) | lo);
346                i += 3;
347                continue;
348            }
349        }
350        out.push(bytes[i]);
351        i += 1;
352    }
353    String::from_utf8(out).ok()
354}
355
356/// Hex digit value of a byte (uppercase or lowercase).
357#[must_use]
358const fn hex_val(b: u8) -> Option<u8> {
359    match b {
360        b'0'..=b'9' => Some(b - b'0'),
361        b'a'..=b'f' => Some(b - b'a' + 10),
362        b'A'..=b'F' => Some(b - b'A' + 10),
363        _ => None,
364    }
365}
366
367/// Decode a Bing click-tracking link (`https://www.bing.com/ck/a?...`).
368///
369/// The real target is carried in the `u=a1<base64url>` parameter. The href
370/// arrives HTML-escaped (`&amp;`), so unescape first, then extract and
371/// decode the base64url payload.
372#[must_use]
373pub fn bing_decode(href: &str) -> Option<String> {
374    let unescaped = href.replace("&amp;", "&");
375    let idx = unescaped.find("u=a1")?;
376    let rest = &unescaped[idx + 4..];
377    let end = rest.find('&').unwrap_or(rest.len());
378    let b64 = rest[..end].replace('-', "+").replace('_', "/");
379    let mut bytes = Vec::with_capacity(b64.len() * 3 / 4);
380    let table: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
381    let mut acc = 0u32;
382    let mut bits = 0u8;
383    for c in b64.bytes().filter(|c| *c != b'=') {
384        let v = table.iter().position(|t| *t == c)?;
385        acc = (acc << 6) | v as u32;
386        bits += 6;
387        if bits >= 8 {
388            bits -= 8;
389            bytes.push((acc >> bits) as u8);
390            acc &= (1 << bits) - 1;
391        }
392    }
393    let target = String::from_utf8(bytes).ok()?;
394    // Only absolute targets are useful — Bing occasionally packs relative
395    // links (e.g. its own /images/search paths) into ck/a redirects.
396    if target.starts_with("http://") || target.starts_with("https://") {
397        Some(target)
398    } else {
399        None
400    }
401}
402
403/// Percent-encode a query for a search URL.
404#[must_use]
405pub fn percent_encode_query(query: &str) -> String {
406    query
407        .chars()
408        .flat_map(|c| match c {
409            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => vec![c],
410            ' ' => vec!['+'],
411            _ => {
412                let mut bytes = [0u8; 4];
413                let s = c.encode_utf8(&mut bytes);
414                s.bytes()
415                    .flat_map(|b| format!("%{b:02X}").chars().collect::<Vec<_>>())
416                    .collect()
417            }
418        })
419        .collect()
420}
421
422/// One parsed search result.
423#[derive(Debug)]
424pub struct SearchResult {
425    pub url: String,
426    pub title: String,
427    pub snippet: String,
428}
429
430/// Search Bing's HTML results (no API key) and parse `li.b_algo` blocks.
431///
432/// Bing currently serves parseable HTML to plain HTTP clients where
433/// DuckDuckGo serves a bot-detection challenge (HTTP 202). If the markup
434/// changes such that no results parse, an empty result list is returned —
435/// callers surface that gracefully.
436pub(crate) fn web_search(
437    query: &str,
438    num_results: usize,
439    timeout: Duration,
440) -> Result<Vec<SearchResult>, wm_core::CoreError> {
441    let url = format!(
442        "https://www.bing.com/search?q={}&count={}",
443        percent_encode_query(query),
444        num_results
445    );
446    safe_url(&url)?;
447    let fetched = fetch_bounded(&url, 300_000, timeout)?;
448    if fetched.status_code == 202 {
449        return Ok(Vec::new());
450    }
451    Ok(parse_bing_results(&fetched.raw, num_results))
452}
453
454/// Parse Bing `li.b_algo` result blocks from raw HTML.
455///
456/// Public and dependency-free so it can be fuzzed directly (see
457/// `fuzz/fuzz_targets/web_parsers.rs`). Never panics — malformed markup
458/// yields fewer results or an empty list.
459#[must_use]
460pub fn parse_bing_results(html: &str, num_results: usize) -> Vec<SearchResult> {
461    let lower = html.to_ascii_lowercase();
462
463    let mut results: Vec<SearchResult> = Vec::new();
464    let mut pos = 0usize;
465    while results.len() < num_results {
466        let block = lower[pos..].find("<li class=\"b_algo\"");
467        let Some(block) = block else { break };
468        let block = pos + block;
469        let block_end = lower[block..]
470            .find("</li>")
471            .map_or(lower.len(), |e| block + e);
472        let chunk = &html[block..block_end];
473        let chunk_lower = &lower[block..block_end];
474
475        // First non-javascript anchor href
476        let mut anchor_at = 0usize;
477        let mut href = None;
478        while anchor_at < chunk.len() {
479            let Some(rel) = chunk_lower[anchor_at..].find("<a ") else {
480                break;
481            };
482            let a_start = anchor_at + rel;
483            let Some(href_start) = chunk_lower[a_start..].find("href=\"") else {
484                break;
485            };
486            let href_start = a_start + href_start + 6;
487            let Some(href_end) = chunk_lower[href_start..].find('"') else {
488                break;
489            };
490            let href_end = href_start + href_end;
491            let candidate = &chunk[href_start..href_end];
492            anchor_at = href_end + 1;
493            if candidate.starts_with("javascript:") || candidate.starts_with('#') {
494                continue;
495            }
496            href = Some(candidate.to_string());
497            break;
498        }
499        let Some(href) = href else {
500            pos = block + 7;
501            continue;
502        };
503
504        // Title: text inside the <h2>…</h2> heading (the result title)
505        let title = {
506            let h2 = chunk_lower.find("<h2").unwrap_or(0);
507            let gt = chunk_lower[h2..].find('>').map_or(0, |e| h2 + e + 1);
508            let close = chunk_lower[gt..]
509                .find("</a>")
510                .map_or(chunk.len(), |e| gt + e);
511            strip_html(&chunk[gt..close.min(chunk.len())])
512        };
513
514        // Snippet: first <p …>…</p> paragraph
515        let snippet = {
516            let p_start = chunk_lower.find("<p ");
517            match p_start {
518                Some(ps) => {
519                    let gt = chunk_lower[ps..].find('>').map(|e| ps + e + 1);
520                    match gt {
521                        Some(gt) => {
522                            let p_close = chunk_lower[gt..].find("</p>").map(|e| gt + e);
523                            match p_close {
524                                Some(pc) => strip_html(&chunk[gt..pc]),
525                                None => String::new(),
526                            }
527                        }
528                        None => String::new(),
529                    }
530                }
531                None => String::new(),
532            }
533        };
534
535        let target = if href.contains("/ck/a") {
536            bing_decode(&href)
537                .filter(|t| is_url_safe(t))
538                .unwrap_or_default()
539        } else if href.starts_with("http") && is_url_safe(&href) {
540            href
541        } else {
542            ddg_target(&href)
543                .filter(|t| is_url_safe(t))
544                .unwrap_or_default()
545        };
546
547        if !target.is_empty() {
548            results.push(SearchResult {
549                url: target,
550                title: title.trim().to_string(),
551                snippet: snippet.trim().to_string(),
552            });
553        }
554        pos = block + 7;
555    }
556    results
557}
558
559/// Build the common response envelope.
560fn fetch_response(fetched: &Fetched, truncated: bool) -> Value {
561    json!({
562        "status": "success",
563        "url": fetched.url,
564        "title": fetched.title,
565        "content": fetched.content,
566        "content_length": fetched.content.len(),
567        "status_code": fetched.status_code,
568        "duration_ms": fetched.duration_ms,
569        "pages_fetched": fetched.pages,
570        "truncated": truncated,
571    })
572}
573
574// ── web.fetch ────────────────────────────────────────────────────────
575
576/// `web.fetch` — fetch a URL and return clean text content.
577pub struct WebFetchTool {
578    stats: ToolStats,
579    effects: EffectRow,
580}
581
582impl WebFetchTool {
583    #[must_use]
584    pub fn new() -> Self {
585        Self {
586            stats: ToolStats::default(),
587            effects: EffectRow::read_only(vec![Resource::Network]),
588        }
589    }
590}
591
592impl Default for WebFetchTool {
593    fn default() -> Self {
594        Self::new()
595    }
596}
597
598#[async_trait]
599impl Tool for WebFetchTool {
600    fn name(&self) -> &str {
601        "web.fetch"
602    }
603    fn gana(&self) -> Gana {
604        Gana::Chariot
605    }
606    fn effects(&self) -> &EffectRow {
607        &self.effects
608    }
609    fn description(&self) -> &str {
610        "Fetch a URL and return clean text content (no browser needed). Args: url (required), max_chars (default 30000), timeout_secs (default 15)."
611    }
612    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
613        let url = args
614            .get("url")
615            .and_then(Value::as_str)
616            .ok_or_else(|| wm_core::CoreError::InvalidArgs("url is required".into()))?;
617        let max_chars = args
618            .get("max_chars")
619            .and_then(Value::as_u64)
620            .unwrap_or(30_000) as usize;
621        // Negative or non-finite timeouts used to reach
622        // Duration::from_secs_f64 and panic the tool.
623        let timeout = args
624            .get("timeout_secs")
625            .and_then(Value::as_f64)
626            .unwrap_or(15.0)
627            .clamp(0.0, 300.0);
628        let url = safe_url(url)?;
629        let fetched = tokio::task::spawn_blocking(move || {
630            fetch_bounded(&url, max_chars, Duration::from_secs_f64(timeout))
631        })
632        .await
633        .map_err(|e| wm_core::CoreError::Tool(format!("web.fetch task: {e}")))??;
634        Ok(fetch_response(&fetched, fetched.content.len() >= max_chars))
635    }
636    fn stats(&self) -> &ToolStats {
637        &self.stats
638    }
639}
640
641// ── web.deep_fetch ───────────────────────────────────────────────────
642
643/// `web.deep_fetch` — full-content retrieval (up to 200K chars).
644pub struct WebDeepFetchTool {
645    stats: ToolStats,
646    effects: EffectRow,
647}
648
649impl WebDeepFetchTool {
650    #[must_use]
651    pub fn new() -> Self {
652        Self {
653            stats: ToolStats::default(),
654            effects: EffectRow::read_only(vec![Resource::Network]),
655        }
656    }
657}
658
659impl Default for WebDeepFetchTool {
660    fn default() -> Self {
661        Self::new()
662    }
663}
664
665#[async_trait]
666impl Tool for WebDeepFetchTool {
667    fn name(&self) -> &str {
668        "web.deep_fetch"
669    }
670    fn gana(&self) -> Gana {
671        Gana::Chariot
672    }
673    fn effects(&self) -> &EffectRow {
674        &self.effects
675    }
676    fn description(&self) -> &str {
677        "Fetch a URL with full-content retrieval (up to 200K chars, no chunk skimming). Args: url (required), max_chars (default 200000), timeout_secs (default 30)."
678    }
679    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
680        let url = args
681            .get("url")
682            .and_then(Value::as_str)
683            .ok_or_else(|| wm_core::CoreError::InvalidArgs("url is required".into()))?;
684        let max_chars = args
685            .get("max_chars")
686            .and_then(Value::as_u64)
687            .unwrap_or(200_000) as usize;
688        let timeout = args
689            .get("timeout_secs")
690            .and_then(Value::as_f64)
691            .unwrap_or(30.0)
692            .clamp(0.0, 300.0);
693        let url = safe_url(url)?;
694        let fetched = tokio::task::spawn_blocking(move || {
695            fetch_bounded(&url, max_chars, Duration::from_secs_f64(timeout))
696        })
697        .await
698        .map_err(|e| wm_core::CoreError::Tool(format!("web.deep_fetch task: {e}")))??;
699        Ok(fetch_response(&fetched, fetched.content.len() >= max_chars))
700    }
701    fn stats(&self) -> &ToolStats {
702        &self.stats
703    }
704}
705
706// ── web.search ───────────────────────────────────────────────────────
707
708/// `web.search` — DuckDuckGo web search (no API key needed).
709pub struct WebSearchTool {
710    stats: ToolStats,
711    effects: EffectRow,
712}
713
714impl WebSearchTool {
715    #[must_use]
716    pub fn new() -> Self {
717        Self {
718            stats: ToolStats::default(),
719            effects: EffectRow::read_only(vec![Resource::Network]),
720        }
721    }
722}
723
724impl Default for WebSearchTool {
725    fn default() -> Self {
726        Self::new()
727    }
728}
729
730#[async_trait]
731impl Tool for WebSearchTool {
732    fn name(&self) -> &str {
733        "web.search"
734    }
735    fn gana(&self) -> Gana {
736        Gana::Chariot
737    }
738    fn effects(&self) -> &EffectRow {
739        &self.effects
740    }
741    fn description(&self) -> &str {
742        "Search the web (Bing HTML, no API key needed). Args: query (required), num_results (default 8), timeout_secs (default 10)."
743    }
744    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
745        let query = args
746            .get("query")
747            .and_then(Value::as_str)
748            .ok_or_else(|| wm_core::CoreError::InvalidArgs("query is required".into()))?;
749        let num_results = args.get("num_results").and_then(Value::as_u64).unwrap_or(8) as usize;
750        let timeout = args
751            .get("timeout_secs")
752            .and_then(Value::as_f64)
753            .unwrap_or(10.0)
754            .clamp(0.0, 300.0);
755        let query = query.to_string();
756        let query_for_task = query.clone();
757        let results = tokio::task::spawn_blocking(move || {
758            web_search(
759                &query_for_task,
760                num_results,
761                Duration::from_secs_f64(timeout),
762            )
763        })
764        .await
765        .map_err(|e| wm_core::CoreError::Tool(format!("web.search task: {e}")))??;
766        let results: Vec<Value> = results
767            .into_iter()
768            .map(|r| json!({"url": r.url, "title": r.title, "snippet": r.snippet}))
769            .collect();
770        Ok(json!({
771            "status": "success",
772            "query": query,
773            "total_results": results.len(),
774            "results": results,
775        }))
776    }
777    fn stats(&self) -> &ToolStats {
778        &self.stats
779    }
780}
781
782// ── web.search_and_read ──────────────────────────────────────────────
783
784/// `web.search_and_read` — search AND fetch content from top results.
785pub struct WebSearchAndReadTool {
786    stats: ToolStats,
787    effects: EffectRow,
788}
789
790impl WebSearchAndReadTool {
791    #[must_use]
792    pub fn new() -> Self {
793        Self {
794            stats: ToolStats::default(),
795            effects: EffectRow::read_only(vec![Resource::Network]),
796        }
797    }
798}
799
800impl Default for WebSearchAndReadTool {
801    fn default() -> Self {
802        Self::new()
803    }
804}
805
806#[async_trait]
807impl Tool for WebSearchAndReadTool {
808    fn name(&self) -> &str {
809        "web.search_and_read"
810    }
811    fn gana(&self) -> Gana {
812        Gana::Chariot
813    }
814    fn effects(&self) -> &EffectRow {
815        &self.effects
816    }
817    fn description(&self) -> &str {
818        "Search the web AND fetch content from top results in one call. Args: query (required), num_results (default 5), max_fetch (default 3), max_chars_per_page (default 15000)."
819    }
820    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
821        let query = args
822            .get("query")
823            .and_then(Value::as_str)
824            .ok_or_else(|| wm_core::CoreError::InvalidArgs("query is required".into()))?;
825        let num_results = args.get("num_results").and_then(Value::as_u64).unwrap_or(5) as usize;
826        let max_fetch = args.get("max_fetch").and_then(Value::as_u64).unwrap_or(3) as usize;
827        let max_chars = args
828            .get("max_chars_per_page")
829            .and_then(Value::as_u64)
830            .unwrap_or(15_000) as usize;
831        // Clamp like web.fetch — negative timeouts must not panic the tool.
832        let timeout = args
833            .get("timeout_secs")
834            .and_then(Value::as_f64)
835            .unwrap_or(15.0)
836            .clamp(0.0, 300.0);
837        let query = query.to_string();
838        let query_for_task = query.clone();
839        let results = tokio::task::spawn_blocking(move || {
840            web_search(
841                &query_for_task,
842                num_results,
843                Duration::from_secs_f64(timeout),
844            )
845        })
846        .await
847        .map_err(|e| wm_core::CoreError::Tool(format!("web.search_and_read task: {e}")))??;
848
849        let mut entries: Vec<Value> = results
850            .into_iter()
851            .map(|r| json!({"url": r.url, "title": r.title, "snippet": r.snippet, "content": null}))
852            .collect();
853
854        let mut fetched_count = 0usize;
855        for entry in &mut entries.iter_mut().take(max_fetch) {
856            let url = entry
857                .get("url")
858                .and_then(Value::as_str)
859                .unwrap_or_default()
860                .to_string();
861            if url.is_empty() || !is_url_safe(&url) {
862                continue;
863            }
864            let url_c = url.clone();
865            let max_c = max_chars;
866            let t = timeout;
867            if let Ok(Ok(fetched)) = tokio::task::spawn_blocking(move || {
868                fetch_bounded(&url_c, max_c, Duration::from_secs_f64(t))
869            })
870            .await
871            {
872                entry["content"] = json!(fetched.content);
873                entry["content_length"] = json!(fetched.content.len());
874                if entry["title"].as_str().unwrap_or_default().is_empty() {
875                    entry["title"] = json!(fetched.title);
876                }
877                fetched_count += 1;
878            }
879        }
880
881        Ok(json!({
882            "status": "success",
883            "query": query,
884            "results": entries,
885            "total_results": entries.len(),
886            "fetched_count": fetched_count,
887        }))
888    }
889    fn stats(&self) -> &ToolStats {
890        &self.stats
891    }
892}
893
894/// Register the web tools (4).
895#[must_use]
896pub fn register_web(registry: &wm_dispatch::ToolRegistry) -> wm_dispatch::ToolRegistry {
897    registry
898        .register(Arc::new(WebFetchTool::new()))
899        .register(Arc::new(WebDeepFetchTool::new()))
900        .register(Arc::new(WebSearchTool::new()))
901        .register(Arc::new(WebSearchAndReadTool::new()))
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907
908    #[tokio::test]
909    async fn negative_timeout_does_not_panic() {
910        // Regression: negative timeout_secs reached Duration::from_secs_f64
911        // and panicked the tool. The value is clamped; the fetch then fails
912        // normally against an invalid URL instead of panicking.
913        let tool = WebFetchTool::new();
914        let result = tool
915            .call(
916                &mut Context::default(),
917                json!({"url": "http://127.0.0.1:1/never", "timeout_secs": -5.0}),
918            )
919            .await;
920        assert!(
921            result.is_err(),
922            "unroutable local URL should fail, not panic"
923        );
924    }
925
926    #[test]
927    fn html_stripping_removes_tags_and_scripts() {
928        let html = "<html><head><title>Test Page</title><script>var x=1;</script></head><body><h1>Hello</h1><p>World&nbsp;wide</p><div>One</div><div>Two</div></body></html>";
929        let text = strip_html(html);
930        assert!(text.contains("Hello"));
931        assert!(text.contains("World wide"));
932        assert!(text.contains("One"));
933        assert!(!text.contains("var x"));
934        assert!(!text.contains("<p>"));
935    }
936
937    #[test]
938    fn html_stripping_decodes_entities() {
939        assert_eq!(
940            strip_html("&amp; &lt;tag&gt; &quot;q&quot; &#65; &#x42;"),
941            "& <tag> \"q\" A B"
942        );
943        assert_eq!(strip_html("&unknown;"), "&unknown;");
944        // '&' not followed by an entity must not swallow a following tag
945        // (fuzz regression)
946        assert_eq!(
947            strip_html("Hello&<script>var x=1;</script><p>World</p>"),
948            "Hello&\nWorld"
949        );
950        // the ';' terminator must not leak into the output (fuzz regression)
951        assert_eq!(strip_html("<p>Hello&nbsp;world</p>"), "Hello world");
952        // entity inside script content must be dropped with the script
953        assert_eq!(
954            strip_html("<script>var a = 1 &amp;&amp; b;</script><p>x</p>"),
955            "x"
956        );
957    }
958
959    #[test]
960    fn title_extraction() {
961        assert_eq!(
962            extract_title("<html><title>  My Page  </title></html>"),
963            Some("My Page".to_string())
964        );
965        assert!(extract_title("<html><body>no title</body></html>").is_none());
966    }
967
968    #[test]
969    fn ddg_redirect_decodes_target() {
970        let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage%3Fa%3D1&rut=abc";
971        assert_eq!(
972            ddg_target(href),
973            Some("https://example.com/page?a=1".to_string())
974        );
975    }
976
977    #[test]
978    fn bing_ck_a_decodes_target() {
979        // u=a1 + base64url of "https://rust-lang.org/"
980        let href = "https://www.bing.com/ck/a?!&amp;&amp;p=abc&amp;u=a1aHR0cHM6Ly9ydXN0LWxhbmcub3JnLw&amp;ntb=1";
981        assert_eq!(
982            bing_decode(href),
983            Some("https://rust-lang.org/".to_string())
984        );
985        // UTF-8 payload (base64url alphabet, padding omitted)
986        let href2 = "https://www.bing.com/ck/a?u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS8_YS1iX3M";
987        assert_eq!(
988            bing_decode(href2),
989            Some("https://example.com/?a-b_s".to_string())
990        );
991        assert_eq!(bing_decode("https://www.bing.com/ck/a?p=1"), None);
992    }
993
994    #[test]
995    fn resolve_url_handles_relative_and_protocol() {
996        assert_eq!(
997            resolve_url("https://example.com/a/b", "/c"),
998            "https://example.com/c"
999        );
1000        assert_eq!(
1001            resolve_url("https://example.com/a/b", "c.html"),
1002            "https://example.com/a/c.html"
1003        );
1004        assert_eq!(
1005            resolve_url("http://example.com/x", "//other.com/y"),
1006            "http://other.com/y"
1007        );
1008        assert_eq!(
1009            resolve_url("https://example.com/x", "https://other.com/y"),
1010            "https://other.com/y"
1011        );
1012    }
1013
1014    #[test]
1015    fn ssrf_guard_rejects_private_and_non_http() {
1016        assert!(safe_url("http://169.254.169.254/latest/meta-data").is_err());
1017        assert!(safe_url("file:///etc/passwd").is_err());
1018        assert!(safe_url("https://example.com").is_ok());
1019    }
1020
1021    #[test]
1022    fn tool_declarations() {
1023        assert_eq!(WebFetchTool::new().name(), "web.fetch");
1024        assert_eq!(WebSearchTool::new().name(), "web.search");
1025        assert_eq!(WebDeepFetchTool::new().name(), "web.deep_fetch");
1026        assert_eq!(WebSearchAndReadTool::new().name(), "web.search_and_read");
1027        let tools: Vec<Box<dyn Tool>> = vec![
1028            Box::new(WebFetchTool::new()),
1029            Box::new(WebSearchTool::new()),
1030            Box::new(WebDeepFetchTool::new()),
1031            Box::new(WebSearchAndReadTool::new()),
1032        ];
1033        for tool in tools {
1034            assert_eq!(tool.gana(), Gana::Chariot);
1035            assert!(tool.effects().writes.is_empty());
1036            assert!(!tool.effects().destructive);
1037            assert_eq!(tool.effects().reads.len(), 1);
1038            assert_eq!(tool.effects().reads[0], Resource::Network);
1039        }
1040    }
1041
1042    #[tokio::test]
1043    async fn fetch_requires_url() {
1044        let tool = WebFetchTool::new();
1045        let mut ctx = Context::default();
1046        let result = tool.call(&mut ctx, json!({})).await;
1047        assert!(result.is_err());
1048    }
1049
1050    #[tokio::test]
1051    async fn search_requires_query() {
1052        let tool = WebSearchTool::new();
1053        let mut ctx = Context::default();
1054        let result = tool.call(&mut ctx, json!({})).await;
1055        assert!(result.is_err());
1056    }
1057}