Skip to main content

zeph_tools/
scrape.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Web scraping executor with SSRF protection and domain policy enforcement.
5//!
6//! Exposes two tools to the LLM:
7//!
8//! - **`web_scrape`** — fetches a URL and extracts elements matching a CSS selector.
9//! - **`fetch`** — fetches a URL and returns the raw response body as UTF-8 text.
10//!
11//! Both tools enforce:
12//!
13//! - HTTPS-only URLs (HTTP and other schemes are rejected).
14//! - DNS resolution followed by a private-IP check to prevent SSRF.
15//! - Optional domain allowlist and denylist from [`ScrapeConfig`].
16//! - Configurable timeout and maximum response body size.
17//! - Redirect following is disabled to prevent open-redirect SSRF bypasses.
18
19use std::net::{IpAddr, SocketAddr};
20use std::sync::Arc;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::time::{Duration, Instant};
23
24use schemars::JsonSchema;
25use serde::Deserialize;
26use url::Url;
27
28use zeph_common::ToolName;
29
30use zeph_sanitizer::IpiFilter;
31
32use crate::audit::{AuditEntry, AuditLogger, AuditResult, EgressEvent, chrono_now};
33use crate::config::{EgressConfig, ScrapeConfig};
34use crate::executor::{
35    ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
36};
37use crate::net::is_private_ip;
38
39/// Strips userinfo (`user:pass@`) and sensitive query params from a URL for safe logging.
40///
41/// Returns the sanitized URL string; falls back to the original if parsing fails.
42fn redact_url_for_log(url: &str) -> String {
43    let Ok(mut parsed) = Url::parse(url) else {
44        return url.to_owned();
45    };
46    // Remove userinfo.
47    let _ = parsed.set_username("");
48    let _ = parsed.set_password(None);
49    // Strip query params whose names suggest secrets (token, key, secret, password, auth, sig).
50    let sensitive = [
51        "token", "key", "secret", "password", "auth", "sig", "api_key", "apikey",
52    ];
53    let filtered: Vec<(String, String)> = parsed
54        .query_pairs()
55        .filter(|(k, _)| {
56            let lower = k.to_lowercase();
57            !sensitive.iter().any(|s| lower.contains(s))
58        })
59        .map(|(k, v)| (k.into_owned(), v.into_owned()))
60        .collect();
61    if filtered.is_empty() {
62        parsed.set_query(None);
63    } else {
64        let q: String = filtered
65            .iter()
66            .map(|(k, v)| format!("{k}={v}"))
67            .collect::<Vec<_>>()
68            .join("&");
69        parsed.set_query(Some(&q));
70    }
71    parsed.to_string()
72}
73
74#[derive(Debug, Deserialize, JsonSchema)]
75struct FetchParams {
76    /// HTTPS URL to fetch
77    url: String,
78}
79
80#[derive(Debug, Deserialize, JsonSchema)]
81struct ScrapeInstruction {
82    /// HTTPS URL to scrape
83    url: String,
84    /// CSS selector
85    select: String,
86    /// Extract mode: text, html, or attr:<name>
87    #[serde(default = "default_extract")]
88    extract: String,
89    /// Max results to return
90    limit: Option<usize>,
91}
92
93fn default_extract() -> String {
94    "text".into()
95}
96
97#[derive(Debug)]
98enum ExtractMode {
99    Text,
100    Html,
101    Attr(String),
102}
103
104impl ExtractMode {
105    fn parse(s: &str) -> Self {
106        match s {
107            "text" => Self::Text,
108            "html" => Self::Html,
109            attr if attr.starts_with("attr:") => {
110                Self::Attr(attr.strip_prefix("attr:").unwrap_or(attr).to_owned())
111            }
112            _ => Self::Text,
113        }
114    }
115}
116
117/// Extracts data from web pages via CSS selectors.
118///
119/// Handles two invocation paths:
120///
121/// 1. **Legacy fenced blocks** — detects ` ```scrape ` blocks in the LLM response, each
122///    containing a JSON scrape instruction object. Dispatched via [`ToolExecutor::execute`].
123/// 2. **Structured tool calls** — dispatched via [`ToolExecutor::execute_tool_call`] for
124///    tool IDs `"web_scrape"` and `"fetch"`.
125///
126/// # Security
127///
128/// - Only HTTPS URLs are accepted. HTTP and other schemes return [`ToolError::InvalidParams`].
129/// - DNS is resolved synchronously and each resolved address is checked against
130///   [`is_private_ip`]. Private addresses are rejected to prevent SSRF.
131/// - HTTP redirects are disabled (`Policy::none()`) to prevent open-redirect bypasses.
132/// - Domain allowlists and denylists from config are enforced before DNS resolution.
133///
134/// # Example
135///
136/// ```rust,no_run
137/// use zeph_tools::{WebScrapeExecutor, ToolExecutor, ToolCall, ScrapeConfig};
138/// use zeph_common::ToolName;
139///
140/// # async fn example() {
141/// let executor = WebScrapeExecutor::new(&ScrapeConfig::default());
142///
143/// let call = ToolCall {
144///     tool_id: ToolName::new("fetch"),
145///     params: {
146///         let mut m = serde_json::Map::new();
147///         m.insert("url".to_owned(), serde_json::json!("https://example.com"));
148///         m
149///     },
150///     caller_id: None,
151///     context: None,
152///     tool_call_id: String::new(),
153///     skill_name: None,
154/// };
155/// let _ = executor.execute_tool_call(&call).await;
156/// # }
157/// ```
158#[derive(Debug)]
159pub struct WebScrapeExecutor {
160    timeout: Duration,
161    max_body_bytes: usize,
162    allowed_domains: Vec<String>,
163    denied_domains: Vec<String>,
164    audit_logger: Option<Arc<AuditLogger>>,
165    egress_config: EgressConfig,
166    egress_tx: Option<tokio::sync::mpsc::Sender<EgressEvent>>,
167    egress_dropped: Arc<AtomicU64>,
168    /// IPI filter applied to every fetched response body before returning to callers.
169    ipi_filter: IpiFilter,
170}
171
172impl WebScrapeExecutor {
173    /// Create a new `WebScrapeExecutor` from configuration.
174    ///
175    /// No network connections are made at construction time.
176    #[must_use]
177    pub fn new(config: &ScrapeConfig) -> Self {
178        Self {
179            timeout: Duration::from_secs(config.timeout),
180            max_body_bytes: config.max_body_bytes,
181            allowed_domains: config.allowed_domains.clone(),
182            denied_domains: config.denied_domains.clone(),
183            audit_logger: None,
184            egress_config: EgressConfig::default(),
185            egress_tx: None,
186            egress_dropped: Arc::new(AtomicU64::new(0)),
187            ipi_filter: IpiFilter::new(config.ipi_filter_threshold),
188        }
189    }
190
191    /// Attach an audit logger. Each tool invocation will emit an [`AuditEntry`].
192    #[must_use]
193    pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
194        self.audit_logger = Some(logger);
195        self
196    }
197
198    /// Configure egress event logging.
199    #[must_use]
200    pub fn with_egress_config(mut self, config: EgressConfig) -> Self {
201        self.egress_config = config;
202        self
203    }
204
205    /// Attach the egress telemetry channel sender and drop counter.
206    ///
207    /// Events are sent via [`tokio::sync::mpsc::Sender::try_send`] — the executor
208    /// never blocks waiting for capacity.
209    #[must_use]
210    pub fn with_egress_tx(
211        mut self,
212        tx: tokio::sync::mpsc::Sender<EgressEvent>,
213        dropped: Arc<AtomicU64>,
214    ) -> Self {
215        self.egress_tx = Some(tx);
216        self.egress_dropped = dropped;
217        self
218    }
219
220    /// Returns a clone of the egress drop counter, for use in the drain task.
221    #[must_use]
222    pub fn egress_dropped(&self) -> Arc<AtomicU64> {
223        Arc::clone(&self.egress_dropped)
224    }
225
226    fn build_client(&self, host: &str, addrs: &[SocketAddr]) -> reqwest::Client {
227        let mut builder = reqwest::Client::builder()
228            .timeout(self.timeout)
229            .redirect(reqwest::redirect::Policy::none());
230        builder = builder.resolve_to_addrs(host, addrs);
231        builder.build().unwrap_or_default()
232    }
233}
234
235impl ToolExecutor for WebScrapeExecutor {
236    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
237        use crate::registry::{InvocationHint, ToolDef};
238        vec![
239            ToolDef {
240                id: "web_scrape".into(),
241                description: "Extract structured data from a web page using CSS selectors.\n\nONLY call this tool when the user has explicitly provided a URL in their message, or when a prior tool call returned a URL to retrieve. NEVER construct, guess, or infer a URL from entity names, brand knowledge, or domain patterns.\n\nParameters: url (string, required) - HTTPS URL; select (string, required) - CSS selector; extract (string, optional) - \"text\", \"html\", or \"attr:<name>\"; limit (integer, optional) - max results\nReturns: extracted text/HTML/attribute values, one per line\nErrors: InvalidParams if URL is not HTTPS or selector is empty; Timeout after configured seconds; connection/DNS failures".into(),
242                schema: schemars::schema_for!(ScrapeInstruction),
243                invocation: InvocationHint::FencedBlock("scrape"),
244                output_schema: None,
245                server_id: None,
246            },
247            ToolDef {
248                id: "fetch".into(),
249                description: "Fetch a URL and return the response body as plain text.\n\nONLY call this tool when the user has explicitly provided a URL in their message, or when a prior tool call returned a URL to retrieve. NEVER construct, guess, or infer a URL from entity names, brand knowledge, or domain patterns. If no URL is present in the conversation, do not call this tool.\n\nParameters: url (string, required) - HTTPS URL to fetch\nReturns: response body as UTF-8 text, truncated if exceeding max body size\nErrors: InvalidParams if URL is not HTTPS; Timeout; SSRF-blocked private IPs; connection failures".into(),
250                schema: schemars::schema_for!(FetchParams),
251                invocation: InvocationHint::ToolCall,
252                output_schema: None,
253                server_id: None,
254            },
255        ]
256    }
257
258    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
259        let blocks = extract_scrape_blocks(response);
260        if blocks.is_empty() {
261            return Ok(None);
262        }
263
264        let mut outputs = Vec::with_capacity(blocks.len());
265        #[allow(clippy::cast_possible_truncation)]
266        let blocks_executed = blocks.len() as u32;
267
268        for block in &blocks {
269            let instruction: ScrapeInstruction = serde_json::from_str(block).map_err(|e| {
270                ToolError::Execution(std::io::Error::new(
271                    std::io::ErrorKind::InvalidData,
272                    e.to_string(),
273                ))
274            })?;
275            let correlation_id = EgressEvent::new_correlation_id();
276            let start = Instant::now();
277            let scrape_result = self
278                .scrape_instruction(&instruction, &correlation_id, None, None)
279                .await;
280            #[allow(clippy::cast_possible_truncation)]
281            let duration_ms = start.elapsed().as_millis() as u64;
282            match scrape_result {
283                Ok(output) => {
284                    self.log_audit(
285                        "web_scrape",
286                        &redact_url_for_log(&instruction.url),
287                        AuditResult::Success,
288                        duration_ms,
289                        None,
290                        None,
291                        None,
292                        Some(correlation_id),
293                    )
294                    .await;
295                    outputs.push(output);
296                }
297                Err(e) => {
298                    let audit_result = tool_error_to_audit_result(&e);
299                    self.log_audit(
300                        "web_scrape",
301                        &redact_url_for_log(&instruction.url),
302                        audit_result,
303                        duration_ms,
304                        Some(&e),
305                        None,
306                        None,
307                        Some(correlation_id),
308                    )
309                    .await;
310                    return Err(e);
311                }
312            }
313        }
314
315        Ok(Some(ToolOutput {
316            tool_name: ToolName::new("web-scrape"),
317            summary: outputs.join("\n\n"),
318            blocks_executed,
319            filter_stats: None,
320            diff: None,
321            streamed: false,
322            terminal_id: None,
323            locations: None,
324            raw_response: None,
325            claim_source: Some(ClaimSource::WebScrape),
326        }))
327    }
328
329    #[cfg_attr(
330        feature = "profiling",
331        tracing::instrument(name = "tools.scrape.fetch", skip_all)
332    )]
333    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
334        match call.tool_id.as_str() {
335            "web_scrape" => {
336                let instruction: ScrapeInstruction = deserialize_params(&call.params)?;
337                let correlation_id = EgressEvent::new_correlation_id();
338                let start = Instant::now();
339                let result = self
340                    .scrape_instruction(
341                        &instruction,
342                        &correlation_id,
343                        call.caller_id.clone(),
344                        call.skill_name.clone(),
345                    )
346                    .await;
347                #[allow(clippy::cast_possible_truncation)]
348                let duration_ms = start.elapsed().as_millis() as u64;
349                self.run_with_audit(
350                    "web_scrape",
351                    "web-scrape",
352                    &redact_url_for_log(&instruction.url),
353                    call.caller_id.clone(),
354                    call.skill_name.clone(),
355                    correlation_id,
356                    duration_ms,
357                    result,
358                )
359                .await
360            }
361            "fetch" => {
362                let p: FetchParams = deserialize_params(&call.params)?;
363                let correlation_id = EgressEvent::new_correlation_id();
364                let start = Instant::now();
365                let result = self
366                    .handle_fetch(
367                        &p,
368                        &correlation_id,
369                        call.caller_id.clone(),
370                        call.skill_name.clone(),
371                    )
372                    .await;
373                #[allow(clippy::cast_possible_truncation)]
374                let duration_ms = start.elapsed().as_millis() as u64;
375                self.run_with_audit(
376                    "fetch",
377                    "fetch",
378                    &redact_url_for_log(&p.url),
379                    call.caller_id.clone(),
380                    call.skill_name.clone(),
381                    correlation_id,
382                    duration_ms,
383                    result,
384                )
385                .await
386            }
387            _ => Ok(None),
388        }
389    }
390
391    fn is_tool_retryable(&self, tool_id: &str) -> bool {
392        matches!(tool_id, "web_scrape" | "fetch")
393    }
394}
395
396fn tool_error_to_audit_result(e: &ToolError) -> AuditResult {
397    match e {
398        ToolError::Blocked { command } => AuditResult::Blocked {
399            reason: command.clone(),
400        },
401        ToolError::Timeout { .. } => AuditResult::Timeout,
402        _ => AuditResult::Error {
403            message: e.to_string(),
404        },
405    }
406}
407
408impl WebScrapeExecutor {
409    #[allow(clippy::too_many_arguments)]
410    async fn run_with_audit(
411        &self,
412        audit_tool_name: &str,
413        public_tool_name: &str,
414        audit_command: &str,
415        caller_id: Option<String>,
416        skill_name: Option<Vec<String>>,
417        correlation_id: String,
418        duration_ms: u64,
419        result: Result<String, ToolError>,
420    ) -> Result<Option<ToolOutput>, ToolError> {
421        match result {
422            Ok(output) => {
423                self.log_audit(
424                    audit_tool_name,
425                    audit_command,
426                    AuditResult::Success,
427                    duration_ms,
428                    None,
429                    caller_id,
430                    skill_name,
431                    Some(correlation_id),
432                )
433                .await;
434                Ok(Some(ToolOutput {
435                    tool_name: ToolName::new(public_tool_name),
436                    summary: output,
437                    blocks_executed: 1,
438                    filter_stats: None,
439                    diff: None,
440                    streamed: false,
441                    terminal_id: None,
442                    locations: None,
443                    raw_response: None,
444                    claim_source: Some(ClaimSource::WebScrape),
445                }))
446            }
447            Err(e) => {
448                let audit_result = tool_error_to_audit_result(&e);
449                self.log_audit(
450                    audit_tool_name,
451                    audit_command,
452                    audit_result,
453                    duration_ms,
454                    Some(&e),
455                    caller_id,
456                    skill_name,
457                    Some(correlation_id),
458                )
459                .await;
460                Err(e)
461            }
462        }
463    }
464
465    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
466    async fn log_audit(
467        &self,
468        tool: &str,
469        command: &str,
470        result: AuditResult,
471        duration_ms: u64,
472        error: Option<&ToolError>,
473        caller_id: Option<String>,
474        skill_name: Option<Vec<String>>,
475        correlation_id: Option<String>,
476    ) {
477        if let Some(ref logger) = self.audit_logger {
478            let (error_category, error_domain, error_phase) =
479                error.map_or((None, None, None), |e| {
480                    let cat = e.category();
481                    (
482                        Some(cat.label().to_owned()),
483                        Some(cat.domain().label().to_owned()),
484                        Some(cat.phase().label().to_owned()),
485                    )
486                });
487            let entry = AuditEntry {
488                timestamp: chrono_now(),
489                tool: tool.into(),
490                command: command.into(),
491                result,
492                duration_ms,
493                error_category,
494                error_domain,
495                error_phase,
496                claim_source: Some(ClaimSource::WebScrape),
497                mcp_server_id: None,
498                injection_flagged: false,
499                embedding_anomalous: false,
500                cross_boundary_mcp_to_acp: false,
501                adversarial_policy_decision: None,
502                exit_code: None,
503                truncated: false,
504                caller_id,
505                skill_name,
506                policy_match: None,
507                correlation_id,
508                vigil_risk: None,
509                execution_env: None,
510                resolved_cwd: None,
511                scope_at_definition: None,
512                scope_at_dispatch: None,
513            };
514            logger.log(&entry).await;
515        }
516    }
517
518    fn send_egress_event(&self, event: EgressEvent) {
519        if let Some(ref tx) = self.egress_tx {
520            match tx.try_send(event) {
521                Ok(()) => {}
522                Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
523                    self.egress_dropped.fetch_add(1, Ordering::Relaxed);
524                }
525                Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
526                    tracing::debug!("egress channel closed; executor continuing without telemetry");
527                }
528            }
529        }
530    }
531
532    async fn log_egress_event(&self, event: &EgressEvent) {
533        if let Some(ref logger) = self.audit_logger {
534            logger.log_egress(event).await;
535        }
536        self.send_egress_event(event.clone());
537    }
538
539    async fn handle_fetch(
540        &self,
541        params: &FetchParams,
542        correlation_id: &str,
543        caller_id: Option<String>,
544        skill_name: Option<Vec<String>>,
545    ) -> Result<String, ToolError> {
546        let parsed = validate_url(&params.url);
547        let host_str = parsed
548            .as_ref()
549            .map(|u| u.host_str().unwrap_or("").to_owned())
550            .unwrap_or_default();
551
552        if let Err(ref _e) = parsed {
553            if self.egress_config.enabled && self.egress_config.log_blocked {
554                let event = Self::make_blocked_event(
555                    "fetch",
556                    &params.url,
557                    &host_str,
558                    correlation_id,
559                    caller_id.clone(),
560                    skill_name.clone(),
561                    "scheme",
562                );
563                self.log_egress_event(&event).await;
564            }
565            return Err(parsed.unwrap_err());
566        }
567        let parsed = parsed.unwrap();
568
569        if let Err(e) = check_domain_policy(
570            parsed.host_str().unwrap_or(""),
571            &self.allowed_domains,
572            &self.denied_domains,
573        ) {
574            if self.egress_config.enabled && self.egress_config.log_blocked {
575                let event = Self::make_blocked_event(
576                    "fetch",
577                    &params.url,
578                    parsed.host_str().unwrap_or(""),
579                    correlation_id,
580                    caller_id.clone(),
581                    skill_name.clone(),
582                    "blocklist",
583                );
584                self.log_egress_event(&event).await;
585            }
586            return Err(e);
587        }
588
589        let (host, addrs) = match resolve_and_validate(&parsed).await {
590            Ok(v) => v,
591            Err(e) => {
592                if self.egress_config.enabled && self.egress_config.log_blocked {
593                    let event = Self::make_blocked_event(
594                        "fetch",
595                        &params.url,
596                        parsed.host_str().unwrap_or(""),
597                        correlation_id,
598                        caller_id.clone(),
599                        skill_name.clone(),
600                        "ssrf",
601                    );
602                    self.log_egress_event(&event).await;
603                }
604                return Err(e);
605            }
606        };
607
608        let body = self
609            .fetch_html(
610                &params.url,
611                &host,
612                &addrs,
613                "fetch",
614                correlation_id,
615                caller_id,
616                skill_name,
617            )
618            .await?;
619        self.apply_ipi_filter(&body, &params.url).await
620    }
621
622    async fn scrape_instruction(
623        &self,
624        instruction: &ScrapeInstruction,
625        correlation_id: &str,
626        caller_id: Option<String>,
627        skill_name: Option<Vec<String>>,
628    ) -> Result<String, ToolError> {
629        let parsed = validate_url(&instruction.url);
630        let host_str = parsed
631            .as_ref()
632            .map(|u| u.host_str().unwrap_or("").to_owned())
633            .unwrap_or_default();
634
635        if let Err(ref _e) = parsed {
636            if self.egress_config.enabled && self.egress_config.log_blocked {
637                let event = Self::make_blocked_event(
638                    "web_scrape",
639                    &instruction.url,
640                    &host_str,
641                    correlation_id,
642                    caller_id.clone(),
643                    skill_name.clone(),
644                    "scheme",
645                );
646                self.log_egress_event(&event).await;
647            }
648            return Err(parsed.unwrap_err());
649        }
650        let parsed = parsed.unwrap();
651
652        if let Err(e) = check_domain_policy(
653            parsed.host_str().unwrap_or(""),
654            &self.allowed_domains,
655            &self.denied_domains,
656        ) {
657            if self.egress_config.enabled && self.egress_config.log_blocked {
658                let event = Self::make_blocked_event(
659                    "web_scrape",
660                    &instruction.url,
661                    parsed.host_str().unwrap_or(""),
662                    correlation_id,
663                    caller_id.clone(),
664                    skill_name.clone(),
665                    "blocklist",
666                );
667                self.log_egress_event(&event).await;
668            }
669            return Err(e);
670        }
671
672        let (host, addrs) = match resolve_and_validate(&parsed).await {
673            Ok(v) => v,
674            Err(e) => {
675                if self.egress_config.enabled && self.egress_config.log_blocked {
676                    let event = Self::make_blocked_event(
677                        "web_scrape",
678                        &instruction.url,
679                        parsed.host_str().unwrap_or(""),
680                        correlation_id,
681                        caller_id.clone(),
682                        skill_name.clone(),
683                        "ssrf",
684                    );
685                    self.log_egress_event(&event).await;
686                }
687                return Err(e);
688            }
689        };
690
691        let html = self
692            .fetch_html(
693                &instruction.url,
694                &host,
695                &addrs,
696                "web_scrape",
697                correlation_id,
698                caller_id,
699                skill_name,
700            )
701            .await?;
702        let selector = instruction.select.clone();
703        let extract = ExtractMode::parse(&instruction.extract);
704        let limit = instruction.limit.unwrap_or(10);
705        let extracted = tokio::task::spawn_blocking(move || {
706            parse_and_extract(&html, &selector, &extract, limit)
707        })
708        .await
709        .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))??;
710        // apply_ipi_filter runs on plain extracted text, not raw HTML
711        self.apply_ipi_filter(&extracted, &instruction.url).await
712    }
713
714    fn make_blocked_event(
715        tool: &str,
716        url: &str,
717        host: &str,
718        correlation_id: &str,
719        caller_id: Option<String>,
720        skill_name: Option<Vec<String>>,
721        block_reason: &'static str,
722    ) -> EgressEvent {
723        EgressEvent {
724            timestamp: chrono_now(),
725            kind: "egress",
726            correlation_id: correlation_id.to_owned(),
727            tool: tool.into(),
728            url: redact_url_for_log(url),
729            host: host.to_owned(),
730            method: "GET".to_owned(),
731            status: None,
732            duration_ms: 0,
733            response_bytes: 0,
734            blocked: true,
735            block_reason: Some(block_reason),
736            caller_id,
737            skill_name,
738            hop: 0,
739        }
740    }
741
742    /// Fetches the HTML at `url`, manually following up to 3 redirects.
743    ///
744    /// Each redirect target is validated with `validate_url` and `resolve_and_validate`
745    /// before following, preventing SSRF via redirect chains. When egress logging is
746    /// enabled, one [`EgressEvent`] is emitted per hop.
747    ///
748    /// # Errors
749    ///
750    /// Returns `ToolError::Blocked` if any redirect target resolves to a private IP.
751    /// Returns `ToolError::Execution` on HTTP errors, too-large bodies, or too many redirects.
752    #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
753    async fn fetch_html(
754        &self,
755        url: &str,
756        host: &str,
757        addrs: &[SocketAddr],
758        tool: &str,
759        correlation_id: &str,
760        caller_id: Option<String>,
761        skill_name: Option<Vec<String>>,
762    ) -> Result<String, ToolError> {
763        const MAX_REDIRECTS: usize = 3;
764
765        let mut current_url = url.to_owned();
766        let mut current_host = host.to_owned();
767        let mut current_addrs = addrs.to_vec();
768
769        for hop in 0..=MAX_REDIRECTS {
770            let hop_start = Instant::now();
771            // Build a per-hop client pinned to the current hop's validated addresses.
772            let client = self.build_client(&current_host, &current_addrs);
773            let resp = client
774                .get(&current_url)
775                .send()
776                .await
777                .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())));
778
779            let resp = match resp {
780                Ok(r) => r,
781                Err(e) => {
782                    if self.egress_config.enabled {
783                        #[allow(clippy::cast_possible_truncation)]
784                        let duration_ms = hop_start.elapsed().as_millis() as u64;
785                        let event = EgressEvent {
786                            timestamp: chrono_now(),
787                            kind: "egress",
788                            correlation_id: correlation_id.to_owned(),
789                            tool: tool.into(),
790                            url: redact_url_for_log(&current_url),
791                            host: current_host.clone(),
792                            method: "GET".to_owned(),
793                            status: None,
794                            duration_ms,
795                            response_bytes: 0,
796                            blocked: false,
797                            block_reason: None,
798                            caller_id: caller_id.clone(),
799                            skill_name: skill_name.clone(),
800                            #[allow(clippy::cast_possible_truncation)]
801                            hop: hop as u8,
802                        };
803                        self.log_egress_event(&event).await;
804                    }
805                    return Err(e);
806                }
807            };
808
809            let status = resp.status();
810
811            if status.is_redirection() {
812                if hop == MAX_REDIRECTS {
813                    return Err(ToolError::Execution(std::io::Error::other(
814                        "too many redirects",
815                    )));
816                }
817
818                let location = resp
819                    .headers()
820                    .get(reqwest::header::LOCATION)
821                    .and_then(|v| v.to_str().ok())
822                    .ok_or_else(|| {
823                        ToolError::Execution(std::io::Error::other("redirect with no Location"))
824                    })?;
825
826                // Resolve relative redirect URLs against the current URL.
827                let base = Url::parse(&current_url)
828                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
829                let next_url = base
830                    .join(location)
831                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
832
833                let validated = validate_url(next_url.as_str());
834                if let Err(ref _e) = validated {
835                    if self.egress_config.enabled && self.egress_config.log_blocked {
836                        #[allow(clippy::cast_possible_truncation)]
837                        let duration_ms = hop_start.elapsed().as_millis() as u64;
838                        let next_host = next_url.host_str().unwrap_or("").to_owned();
839                        let event = EgressEvent {
840                            timestamp: chrono_now(),
841                            kind: "egress",
842                            correlation_id: correlation_id.to_owned(),
843                            tool: tool.into(),
844                            url: redact_url_for_log(next_url.as_str()),
845                            host: next_host,
846                            method: "GET".to_owned(),
847                            status: None,
848                            duration_ms,
849                            response_bytes: 0,
850                            blocked: true,
851                            block_reason: Some("ssrf"),
852                            caller_id: caller_id.clone(),
853                            skill_name: skill_name.clone(),
854                            #[allow(clippy::cast_possible_truncation)]
855                            hop: (hop + 1) as u8,
856                        };
857                        self.log_egress_event(&event).await;
858                    }
859                    return Err(validated.unwrap_err());
860                }
861                let validated = validated.unwrap();
862                let resolve_result = resolve_and_validate(&validated).await;
863                if let Err(ref _e) = resolve_result {
864                    if self.egress_config.enabled && self.egress_config.log_blocked {
865                        #[allow(clippy::cast_possible_truncation)]
866                        let duration_ms = hop_start.elapsed().as_millis() as u64;
867                        let next_host = next_url.host_str().unwrap_or("").to_owned();
868                        let event = EgressEvent {
869                            timestamp: chrono_now(),
870                            kind: "egress",
871                            correlation_id: correlation_id.to_owned(),
872                            tool: tool.into(),
873                            url: redact_url_for_log(next_url.as_str()),
874                            host: next_host,
875                            method: "GET".to_owned(),
876                            status: None,
877                            duration_ms,
878                            response_bytes: 0,
879                            blocked: true,
880                            block_reason: Some("ssrf"),
881                            caller_id: caller_id.clone(),
882                            skill_name: skill_name.clone(),
883                            #[allow(clippy::cast_possible_truncation)]
884                            hop: (hop + 1) as u8,
885                        };
886                        self.log_egress_event(&event).await;
887                    }
888                    return Err(resolve_result.unwrap_err());
889                }
890                let (next_host, next_addrs) = resolve_result.unwrap();
891
892                current_url = next_url.to_string();
893                current_host = next_host;
894                current_addrs = next_addrs;
895                continue;
896            }
897
898            if !status.is_success() {
899                if self.egress_config.enabled {
900                    #[allow(clippy::cast_possible_truncation)]
901                    let duration_ms = hop_start.elapsed().as_millis() as u64;
902                    let event = EgressEvent {
903                        timestamp: chrono_now(),
904                        kind: "egress",
905                        correlation_id: correlation_id.to_owned(),
906                        tool: tool.into(),
907                        url: redact_url_for_log(&current_url),
908                        host: current_host.clone(),
909                        method: "GET".to_owned(),
910                        status: Some(status.as_u16()),
911                        duration_ms,
912                        response_bytes: 0,
913                        blocked: false,
914                        block_reason: None,
915                        caller_id: caller_id.clone(),
916                        skill_name: skill_name.clone(),
917                        #[allow(clippy::cast_possible_truncation)]
918                        hop: hop as u8,
919                    };
920                    self.log_egress_event(&event).await;
921                }
922                return Err(ToolError::Http {
923                    status: status.as_u16(),
924                    message: status.canonical_reason().unwrap_or("unknown").to_owned(),
925                });
926            }
927
928            let bytes = resp
929                .bytes()
930                .await
931                .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
932
933            if bytes.len() > self.max_body_bytes {
934                if self.egress_config.enabled {
935                    #[allow(clippy::cast_possible_truncation)]
936                    let duration_ms = hop_start.elapsed().as_millis() as u64;
937                    let event = EgressEvent {
938                        timestamp: chrono_now(),
939                        kind: "egress",
940                        correlation_id: correlation_id.to_owned(),
941                        tool: tool.into(),
942                        url: redact_url_for_log(&current_url),
943                        host: current_host.clone(),
944                        method: "GET".to_owned(),
945                        status: Some(status.as_u16()),
946                        duration_ms,
947                        response_bytes: bytes.len(),
948                        blocked: false,
949                        block_reason: None,
950                        caller_id: caller_id.clone(),
951                        skill_name: skill_name.clone(),
952                        #[allow(clippy::cast_possible_truncation)]
953                        hop: hop as u8,
954                    };
955                    self.log_egress_event(&event).await;
956                }
957                return Err(ToolError::Execution(std::io::Error::other(format!(
958                    "response too large: {} bytes (max: {})",
959                    bytes.len(),
960                    self.max_body_bytes,
961                ))));
962            }
963
964            // Success — emit egress event.
965            if self.egress_config.enabled {
966                #[allow(clippy::cast_possible_truncation)]
967                let duration_ms = hop_start.elapsed().as_millis() as u64;
968                let response_bytes = if self.egress_config.log_response_bytes {
969                    bytes.len()
970                } else {
971                    0
972                };
973                let event = EgressEvent {
974                    timestamp: chrono_now(),
975                    kind: "egress",
976                    correlation_id: correlation_id.to_owned(),
977                    tool: tool.into(),
978                    url: redact_url_for_log(&current_url),
979                    host: current_host.clone(),
980                    method: "GET".to_owned(),
981                    status: Some(status.as_u16()),
982                    duration_ms,
983                    response_bytes,
984                    blocked: false,
985                    block_reason: None,
986                    caller_id: caller_id.clone(),
987                    skill_name: skill_name.clone(),
988                    #[allow(clippy::cast_possible_truncation)]
989                    hop: hop as u8,
990                };
991                self.log_egress_event(&event).await;
992            }
993
994            return String::from_utf8(bytes.to_vec())
995                .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())));
996        }
997
998        Err(ToolError::Execution(std::io::Error::other(
999            "too many redirects",
1000        )))
1001    }
1002
1003    /// Apply IPI filter to a fetched response body.
1004    ///
1005    /// Runs regex scanning on a blocking thread via `spawn_blocking` to avoid
1006    /// stalling the tokio executor on large inputs. When score >= threshold,
1007    /// prepends a warning header and emits a `tracing::warn!` log.
1008    ///
1009    /// # Errors
1010    ///
1011    /// Returns a [`ToolError`] if the blocking scan task panics.
1012    #[tracing::instrument(name = "tools.scrape.apply_ipi_filter", skip(self, body), fields(body_len = body.len()))]
1013    async fn apply_ipi_filter(&self, body: &str, url: &str) -> Result<String, ToolError> {
1014        let verdict = self
1015            .ipi_filter
1016            .filter_async(body.to_owned())
1017            .await
1018            .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1019        if !verdict.patterns_found.is_empty() {
1020            tracing::warn!(
1021                url = url,
1022                score = verdict.score,
1023                patterns = ?verdict.patterns_found,
1024                "IPI patterns detected in fetched content"
1025            );
1026        }
1027        // verdict.sanitized == body only when score < threshold (no redaction)
1028        if verdict.sanitized == body {
1029            Ok(verdict.sanitized)
1030        } else {
1031            Ok(format!(
1032                "[IPI WARNING: score={:.2}, patterns={}] {}",
1033                verdict.score,
1034                verdict.patterns_found.join(", "),
1035                verdict.sanitized,
1036            ))
1037        }
1038    }
1039}
1040
1041fn extract_scrape_blocks(text: &str) -> Vec<&str> {
1042    crate::executor::extract_fenced_blocks(text, "scrape")
1043}
1044
1045/// Check host against the domain allowlist/denylist from `ScrapeConfig`.
1046///
1047/// Logic:
1048/// 1. If `denied_domains` matches the host → block.
1049/// 2. If `allowed_domains` is non-empty:
1050///    a. IP address hosts are always rejected (no pattern can match a bare IP).
1051///    b. Hosts not matching any entry → block.
1052/// 3. Otherwise → allow.
1053///
1054/// Wildcard prefix matching: `*.example.com` matches `sub.example.com` but NOT `example.com`.
1055/// Multiple wildcards are not supported; patterns with more than one `*` are treated as exact.
1056fn check_domain_policy(
1057    host: &str,
1058    allowed_domains: &[String],
1059    denied_domains: &[String],
1060) -> Result<(), ToolError> {
1061    if denied_domains.iter().any(|p| domain_matches(p, host)) {
1062        return Err(ToolError::Blocked {
1063            command: format!("domain blocked by denylist: {host}"),
1064        });
1065    }
1066    if !allowed_domains.is_empty() {
1067        // Bare IP addresses cannot match any domain pattern — reject when allowlist is active.
1068        let is_ip = host.parse::<std::net::IpAddr>().is_ok()
1069            || (host.starts_with('[') && host.ends_with(']'));
1070        if is_ip {
1071            return Err(ToolError::Blocked {
1072                command: format!(
1073                    "bare IP address not allowed when domain allowlist is active: {host}"
1074                ),
1075            });
1076        }
1077        if !allowed_domains.iter().any(|p| domain_matches(p, host)) {
1078            return Err(ToolError::Blocked {
1079                command: format!("domain not in allowlist: {host}"),
1080            });
1081        }
1082    }
1083    Ok(())
1084}
1085
1086// Domain pattern matching is delegated to the shared `domain_match` module.
1087use crate::domain_match::domain_matches;
1088
1089fn validate_url(raw: &str) -> Result<Url, ToolError> {
1090    let parsed = Url::parse(raw).map_err(|_| ToolError::Blocked {
1091        command: format!("invalid URL: {raw}"),
1092    })?;
1093
1094    if parsed.scheme() != "https" {
1095        return Err(ToolError::Blocked {
1096            command: format!("scheme not allowed: {}", parsed.scheme()),
1097        });
1098    }
1099
1100    if let Some(host) = parsed.host()
1101        && is_private_host(&host)
1102    {
1103        return Err(ToolError::Blocked {
1104            command: format!(
1105                "private/local host blocked: {}",
1106                parsed.host_str().unwrap_or("")
1107            ),
1108        });
1109    }
1110
1111    Ok(parsed)
1112}
1113
1114fn is_private_host(host: &url::Host<&str>) -> bool {
1115    match host {
1116        url::Host::Domain(d) => {
1117            // Exact match or subdomain of localhost (e.g. foo.localhost)
1118            // and .internal/.local TLDs used in cloud/k8s environments.
1119            #[allow(clippy::case_sensitive_file_extension_comparisons)]
1120            {
1121                *d == "localhost"
1122                    || d.ends_with(".localhost")
1123                    || d.ends_with(".internal")
1124                    || d.ends_with(".local")
1125            }
1126        }
1127        url::Host::Ipv4(v4) => is_private_ip(IpAddr::V4(*v4)),
1128        url::Host::Ipv6(v6) => is_private_ip(IpAddr::V6(*v6)),
1129    }
1130}
1131
1132/// Resolves DNS for the URL host, validates all resolved IPs against private ranges,
1133/// and returns the hostname and validated socket addresses.
1134///
1135/// Returning the addresses allows the caller to pin the HTTP client to these exact
1136/// addresses, eliminating TOCTOU between DNS validation and the actual connection.
1137///
1138/// Delegates the actual lookup+validation loop to the shared
1139/// [`zeph_common::net::resolve_and_validate`] helper (also used by `zeph-a2a`'s client)
1140/// and maps its neutral error into this crate's [`ToolError`].
1141#[tracing::instrument(name = "tools.scrape.dns.resolve", skip(url), fields(host = url.host_str().unwrap_or("")))]
1142async fn resolve_and_validate(url: &Url) -> Result<(String, Vec<SocketAddr>), ToolError> {
1143    let Some(host) = url.host_str() else {
1144        return Ok((String::new(), vec![]));
1145    };
1146    let port = url.port_or_known_default().unwrap_or(443);
1147    let addrs = zeph_common::net::resolve_and_validate(host, port)
1148        .await
1149        .map_err(|e| match e {
1150            zeph_common::net::ResolveError::Timeout(timeout) => ToolError::Timeout {
1151                timeout_secs: timeout.as_secs(),
1152            },
1153            zeph_common::net::ResolveError::Lookup(io_err) => ToolError::Blocked {
1154                command: format!("DNS resolution failed: {io_err}"),
1155            },
1156            zeph_common::net::ResolveError::PrivateAddress { host, addr } => ToolError::Blocked {
1157                command: format!("SSRF protection: private IP {addr} for host {host}"),
1158            },
1159            other => ToolError::Blocked {
1160                command: format!("DNS resolution failed: {other}"),
1161            },
1162        })?;
1163    Ok((host.to_owned(), addrs))
1164}
1165
1166fn parse_and_extract(
1167    html: &str,
1168    selector: &str,
1169    extract: &ExtractMode,
1170    limit: usize,
1171) -> Result<String, ToolError> {
1172    let soup = scrape_core::Soup::parse(html);
1173
1174    let tags = soup.find_all(selector).map_err(|e| {
1175        ToolError::Execution(std::io::Error::new(
1176            std::io::ErrorKind::InvalidData,
1177            format!("invalid selector: {e}"),
1178        ))
1179    })?;
1180
1181    let mut results = Vec::new();
1182
1183    for tag in tags.into_iter().take(limit) {
1184        let value = match extract {
1185            ExtractMode::Text => tag.text(),
1186            ExtractMode::Html => tag.inner_html(),
1187            ExtractMode::Attr(name) => tag.get(name).unwrap_or_default().to_owned(),
1188        };
1189        if !value.trim().is_empty() {
1190            results.push(value.trim().to_owned());
1191        }
1192    }
1193
1194    if results.is_empty() {
1195        Ok(format!("No results for selector: {selector}"))
1196    } else {
1197        Ok(results.join("\n"))
1198    }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204    use std::assert_matches;
1205
1206    // --- extract_scrape_blocks ---
1207
1208    #[test]
1209    fn extract_single_block() {
1210        let text =
1211            "Here:\n```scrape\n{\"url\":\"https://example.com\",\"select\":\"h1\"}\n```\nDone.";
1212        let blocks = extract_scrape_blocks(text);
1213        assert_eq!(blocks.len(), 1);
1214        assert!(blocks[0].contains("example.com"));
1215    }
1216
1217    #[test]
1218    fn extract_multiple_blocks() {
1219        let text = "```scrape\n{\"url\":\"https://a.com\",\"select\":\"h1\"}\n```\ntext\n```scrape\n{\"url\":\"https://b.com\",\"select\":\"p\"}\n```";
1220        let blocks = extract_scrape_blocks(text);
1221        assert_eq!(blocks.len(), 2);
1222    }
1223
1224    #[test]
1225    fn no_blocks_returns_empty() {
1226        let blocks = extract_scrape_blocks("plain text, no code blocks");
1227        assert!(blocks.is_empty());
1228    }
1229
1230    #[test]
1231    fn unclosed_block_ignored() {
1232        let blocks = extract_scrape_blocks("```scrape\n{\"url\":\"https://x.com\"}");
1233        assert!(blocks.is_empty());
1234    }
1235
1236    #[test]
1237    fn non_scrape_block_ignored() {
1238        let text =
1239            "```bash\necho hi\n```\n```scrape\n{\"url\":\"https://x.com\",\"select\":\"h1\"}\n```";
1240        let blocks = extract_scrape_blocks(text);
1241        assert_eq!(blocks.len(), 1);
1242        assert!(blocks[0].contains("x.com"));
1243    }
1244
1245    #[test]
1246    fn multiline_json_block() {
1247        let text =
1248            "```scrape\n{\n  \"url\": \"https://example.com\",\n  \"select\": \"h1\"\n}\n```";
1249        let blocks = extract_scrape_blocks(text);
1250        assert_eq!(blocks.len(), 1);
1251        let instr: ScrapeInstruction = serde_json::from_str(blocks[0]).unwrap();
1252        assert_eq!(instr.url, "https://example.com");
1253    }
1254
1255    // --- ScrapeInstruction parsing ---
1256
1257    #[test]
1258    fn parse_valid_instruction() {
1259        let json = r#"{"url":"https://example.com","select":"h1","extract":"text","limit":5}"#;
1260        let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1261        assert_eq!(instr.url, "https://example.com");
1262        assert_eq!(instr.select, "h1");
1263        assert_eq!(instr.extract, "text");
1264        assert_eq!(instr.limit, Some(5));
1265    }
1266
1267    #[test]
1268    fn parse_minimal_instruction() {
1269        let json = r#"{"url":"https://example.com","select":"p"}"#;
1270        let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1271        assert_eq!(instr.extract, "text");
1272        assert!(instr.limit.is_none());
1273    }
1274
1275    #[test]
1276    fn parse_attr_extract() {
1277        let json = r#"{"url":"https://example.com","select":"a","extract":"attr:href"}"#;
1278        let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
1279        assert_eq!(instr.extract, "attr:href");
1280    }
1281
1282    #[test]
1283    fn parse_invalid_json_errors() {
1284        let result = serde_json::from_str::<ScrapeInstruction>("not json");
1285        assert!(result.is_err());
1286    }
1287
1288    // --- ExtractMode ---
1289
1290    #[test]
1291    fn extract_mode_text() {
1292        assert_matches!(ExtractMode::parse("text"), ExtractMode::Text);
1293    }
1294
1295    #[test]
1296    fn extract_mode_html() {
1297        assert_matches!(ExtractMode::parse("html"), ExtractMode::Html);
1298    }
1299
1300    #[test]
1301    fn extract_mode_attr() {
1302        let mode = ExtractMode::parse("attr:href");
1303        assert_matches!(mode, ExtractMode::Attr(ref s) if s == "href");
1304    }
1305
1306    #[test]
1307    fn extract_mode_unknown_defaults_to_text() {
1308        assert_matches!(ExtractMode::parse("unknown"), ExtractMode::Text);
1309    }
1310
1311    // --- validate_url ---
1312
1313    #[test]
1314    fn valid_https_url() {
1315        assert!(validate_url("https://example.com").is_ok());
1316    }
1317
1318    #[test]
1319    fn http_rejected() {
1320        let err = validate_url("http://example.com").unwrap_err();
1321        assert_matches!(err, ToolError::Blocked { .. });
1322    }
1323
1324    #[test]
1325    fn ftp_rejected() {
1326        let err = validate_url("ftp://files.example.com").unwrap_err();
1327        assert_matches!(err, ToolError::Blocked { .. });
1328    }
1329
1330    #[test]
1331    fn file_rejected() {
1332        let err = validate_url("file:///etc/passwd").unwrap_err();
1333        assert_matches!(err, ToolError::Blocked { .. });
1334    }
1335
1336    #[test]
1337    fn invalid_url_rejected() {
1338        let err = validate_url("not a url").unwrap_err();
1339        assert_matches!(err, ToolError::Blocked { .. });
1340    }
1341
1342    #[test]
1343    fn localhost_blocked() {
1344        let err = validate_url("https://localhost/path").unwrap_err();
1345        assert_matches!(err, ToolError::Blocked { .. });
1346    }
1347
1348    #[test]
1349    fn loopback_ip_blocked() {
1350        let err = validate_url("https://127.0.0.1/path").unwrap_err();
1351        assert_matches!(err, ToolError::Blocked { .. });
1352    }
1353
1354    #[test]
1355    fn private_10_blocked() {
1356        let err = validate_url("https://10.0.0.1/api").unwrap_err();
1357        assert_matches!(err, ToolError::Blocked { .. });
1358    }
1359
1360    #[test]
1361    fn private_172_blocked() {
1362        let err = validate_url("https://172.16.0.1/api").unwrap_err();
1363        assert_matches!(err, ToolError::Blocked { .. });
1364    }
1365
1366    #[test]
1367    fn private_192_blocked() {
1368        let err = validate_url("https://192.168.1.1/api").unwrap_err();
1369        assert_matches!(err, ToolError::Blocked { .. });
1370    }
1371
1372    #[test]
1373    fn ipv6_loopback_blocked() {
1374        let err = validate_url("https://[::1]/path").unwrap_err();
1375        assert_matches!(err, ToolError::Blocked { .. });
1376    }
1377
1378    #[test]
1379    fn public_ip_allowed() {
1380        assert!(validate_url("https://93.184.216.34/page").is_ok());
1381    }
1382
1383    // --- parse_and_extract ---
1384
1385    #[test]
1386    fn extract_text_from_html() {
1387        let html = "<html><body><h1>Hello World</h1><p>Content</p></body></html>";
1388        let result = parse_and_extract(html, "h1", &ExtractMode::Text, 10).unwrap();
1389        assert_eq!(result, "Hello World");
1390    }
1391
1392    #[test]
1393    fn extract_multiple_elements() {
1394        let html = "<ul><li>A</li><li>B</li><li>C</li></ul>";
1395        let result = parse_and_extract(html, "li", &ExtractMode::Text, 10).unwrap();
1396        assert_eq!(result, "A\nB\nC");
1397    }
1398
1399    #[test]
1400    fn extract_with_limit() {
1401        let html = "<ul><li>A</li><li>B</li><li>C</li></ul>";
1402        let result = parse_and_extract(html, "li", &ExtractMode::Text, 2).unwrap();
1403        assert_eq!(result, "A\nB");
1404    }
1405
1406    #[test]
1407    fn extract_attr_href() {
1408        let html = r#"<a href="https://example.com">Link</a>"#;
1409        let result =
1410            parse_and_extract(html, "a", &ExtractMode::Attr("href".to_owned()), 10).unwrap();
1411        assert_eq!(result, "https://example.com");
1412    }
1413
1414    #[test]
1415    fn extract_inner_html() {
1416        let html = "<div><span>inner</span></div>";
1417        let result = parse_and_extract(html, "div", &ExtractMode::Html, 10).unwrap();
1418        assert!(result.contains("<span>inner</span>"));
1419    }
1420
1421    #[test]
1422    fn no_matches_returns_message() {
1423        let html = "<html><body><p>text</p></body></html>";
1424        let result = parse_and_extract(html, "h1", &ExtractMode::Text, 10).unwrap();
1425        assert!(result.starts_with("No results for selector:"));
1426    }
1427
1428    #[test]
1429    fn empty_text_skipped() {
1430        let html = "<ul><li>  </li><li>A</li></ul>";
1431        let result = parse_and_extract(html, "li", &ExtractMode::Text, 10).unwrap();
1432        assert_eq!(result, "A");
1433    }
1434
1435    #[test]
1436    fn invalid_selector_errors() {
1437        let html = "<html><body></body></html>";
1438        let result = parse_and_extract(html, "[[[invalid", &ExtractMode::Text, 10);
1439        assert!(result.is_err());
1440    }
1441
1442    #[test]
1443    fn empty_html_returns_no_results() {
1444        let result = parse_and_extract("", "h1", &ExtractMode::Text, 10).unwrap();
1445        assert!(result.starts_with("No results for selector:"));
1446    }
1447
1448    #[test]
1449    fn nested_selector() {
1450        let html = "<div><span>inner</span></div><span>outer</span>";
1451        let result = parse_and_extract(html, "div > span", &ExtractMode::Text, 10).unwrap();
1452        assert_eq!(result, "inner");
1453    }
1454
1455    #[test]
1456    fn attr_missing_returns_empty() {
1457        let html = r"<a>No href</a>";
1458        let result =
1459            parse_and_extract(html, "a", &ExtractMode::Attr("href".to_owned()), 10).unwrap();
1460        assert!(result.starts_with("No results for selector:"));
1461    }
1462
1463    #[test]
1464    fn extract_html_mode() {
1465        let html = "<div><b>bold</b> text</div>";
1466        let result = parse_and_extract(html, "div", &ExtractMode::Html, 10).unwrap();
1467        assert!(result.contains("<b>bold</b>"));
1468    }
1469
1470    #[test]
1471    fn limit_zero_returns_no_results() {
1472        let html = "<ul><li>A</li><li>B</li></ul>";
1473        let result = parse_and_extract(html, "li", &ExtractMode::Text, 0).unwrap();
1474        assert!(result.starts_with("No results for selector:"));
1475    }
1476
1477    // --- validate_url edge cases ---
1478
1479    #[test]
1480    fn url_with_port_allowed() {
1481        assert!(validate_url("https://example.com:8443/path").is_ok());
1482    }
1483
1484    #[test]
1485    fn link_local_ip_blocked() {
1486        let err = validate_url("https://169.254.1.1/path").unwrap_err();
1487        assert_matches!(err, ToolError::Blocked { .. });
1488    }
1489
1490    #[test]
1491    fn url_no_scheme_rejected() {
1492        let err = validate_url("example.com/path").unwrap_err();
1493        assert_matches!(err, ToolError::Blocked { .. });
1494    }
1495
1496    #[test]
1497    fn unspecified_ipv4_blocked() {
1498        let err = validate_url("https://0.0.0.0/path").unwrap_err();
1499        assert_matches!(err, ToolError::Blocked { .. });
1500    }
1501
1502    #[test]
1503    fn broadcast_ipv4_blocked() {
1504        let err = validate_url("https://255.255.255.255/path").unwrap_err();
1505        assert_matches!(err, ToolError::Blocked { .. });
1506    }
1507
1508    #[test]
1509    fn ipv6_link_local_blocked() {
1510        let err = validate_url("https://[fe80::1]/path").unwrap_err();
1511        assert_matches!(err, ToolError::Blocked { .. });
1512    }
1513
1514    #[test]
1515    fn ipv6_unique_local_blocked() {
1516        let err = validate_url("https://[fd12::1]/path").unwrap_err();
1517        assert_matches!(err, ToolError::Blocked { .. });
1518    }
1519
1520    #[test]
1521    fn ipv4_mapped_ipv6_loopback_blocked() {
1522        let err = validate_url("https://[::ffff:127.0.0.1]/path").unwrap_err();
1523        assert_matches!(err, ToolError::Blocked { .. });
1524    }
1525
1526    #[test]
1527    fn ipv4_mapped_ipv6_private_blocked() {
1528        let err = validate_url("https://[::ffff:10.0.0.1]/path").unwrap_err();
1529        assert_matches!(err, ToolError::Blocked { .. });
1530    }
1531
1532    // --- WebScrapeExecutor (no-network) ---
1533
1534    #[tokio::test]
1535    async fn executor_no_blocks_returns_none() {
1536        let config = ScrapeConfig::default();
1537        let executor = WebScrapeExecutor::new(&config);
1538        let result = executor.execute("plain text").await;
1539        assert!(result.unwrap().is_none());
1540    }
1541
1542    #[tokio::test]
1543    async fn executor_invalid_json_errors() {
1544        let config = ScrapeConfig::default();
1545        let executor = WebScrapeExecutor::new(&config);
1546        let response = "```scrape\nnot json\n```";
1547        let result = executor.execute(response).await;
1548        assert_matches!(result, Err(ToolError::Execution(_)));
1549    }
1550
1551    #[tokio::test]
1552    async fn executor_blocked_url_errors() {
1553        let config = ScrapeConfig::default();
1554        let executor = WebScrapeExecutor::new(&config);
1555        let response = "```scrape\n{\"url\":\"http://example.com\",\"select\":\"h1\"}\n```";
1556        let result = executor.execute(response).await;
1557        assert_matches!(result, Err(ToolError::Blocked { .. }));
1558    }
1559
1560    #[tokio::test]
1561    async fn executor_private_ip_blocked() {
1562        let config = ScrapeConfig::default();
1563        let executor = WebScrapeExecutor::new(&config);
1564        let response = "```scrape\n{\"url\":\"https://192.168.1.1/api\",\"select\":\"h1\"}\n```";
1565        let result = executor.execute(response).await;
1566        assert_matches!(result, Err(ToolError::Blocked { .. }));
1567    }
1568
1569    #[tokio::test]
1570    async fn executor_unreachable_host_returns_error() {
1571        let config = ScrapeConfig {
1572            timeout: 1,
1573            max_body_bytes: 1_048_576,
1574            ..Default::default()
1575        };
1576        let executor = WebScrapeExecutor::new(&config);
1577        let response = "```scrape\n{\"url\":\"https://192.0.2.1:1/page\",\"select\":\"h1\"}\n```";
1578        let result = executor.execute(response).await;
1579        assert_matches!(result, Err(ToolError::Execution(_)));
1580    }
1581
1582    #[tokio::test]
1583    async fn executor_localhost_url_blocked() {
1584        let config = ScrapeConfig::default();
1585        let executor = WebScrapeExecutor::new(&config);
1586        let response = "```scrape\n{\"url\":\"https://localhost:9999/api\",\"select\":\"h1\"}\n```";
1587        let result = executor.execute(response).await;
1588        assert_matches!(result, Err(ToolError::Blocked { .. }));
1589    }
1590
1591    #[tokio::test]
1592    async fn executor_empty_text_returns_none() {
1593        let config = ScrapeConfig::default();
1594        let executor = WebScrapeExecutor::new(&config);
1595        let result = executor.execute("").await;
1596        assert!(result.unwrap().is_none());
1597    }
1598
1599    #[tokio::test]
1600    async fn executor_multiple_blocks_first_blocked() {
1601        let config = ScrapeConfig::default();
1602        let executor = WebScrapeExecutor::new(&config);
1603        let response = "```scrape\n{\"url\":\"http://evil.com\",\"select\":\"h1\"}\n```\n\
1604             ```scrape\n{\"url\":\"https://ok.com\",\"select\":\"h1\"}\n```";
1605        let result = executor.execute(response).await;
1606        assert!(result.is_err());
1607    }
1608
1609    #[test]
1610    fn validate_url_empty_string() {
1611        let err = validate_url("").unwrap_err();
1612        assert_matches!(err, ToolError::Blocked { .. });
1613    }
1614
1615    #[test]
1616    fn validate_url_javascript_scheme_blocked() {
1617        let err = validate_url("javascript:alert(1)").unwrap_err();
1618        assert_matches!(err, ToolError::Blocked { .. });
1619    }
1620
1621    #[test]
1622    fn validate_url_data_scheme_blocked() {
1623        let err = validate_url("data:text/html,<h1>hi</h1>").unwrap_err();
1624        assert_matches!(err, ToolError::Blocked { .. });
1625    }
1626
1627    #[test]
1628    fn is_private_host_public_domain_is_false() {
1629        let host: url::Host<&str> = url::Host::Domain("example.com");
1630        assert!(!is_private_host(&host));
1631    }
1632
1633    #[test]
1634    fn is_private_host_localhost_is_true() {
1635        let host: url::Host<&str> = url::Host::Domain("localhost");
1636        assert!(is_private_host(&host));
1637    }
1638
1639    #[test]
1640    fn is_private_host_ipv6_unspecified_is_true() {
1641        let host = url::Host::Ipv6(std::net::Ipv6Addr::UNSPECIFIED);
1642        assert!(is_private_host(&host));
1643    }
1644
1645    #[test]
1646    fn is_private_host_public_ipv6_is_false() {
1647        let host = url::Host::Ipv6("2001:db8::1".parse().unwrap());
1648        assert!(!is_private_host(&host));
1649    }
1650
1651    // --- fetch_html redirect logic: wiremock HTTP server tests ---
1652    //
1653    // These tests use a local wiremock server to exercise the redirect-following logic
1654    // in `fetch_html` without requiring an external HTTPS connection. The server binds to
1655    // 127.0.0.1, and tests call `fetch_html` directly (bypassing `validate_url`) to avoid
1656    // the SSRF guard that would otherwise block loopback connections.
1657
1658    /// Helper: returns executor + (`server_url`, `server_addr`) from a running wiremock mock server.
1659    /// The server address is passed to `fetch_html` via `resolve_to_addrs` so the client
1660    /// connects to the mock instead of doing a real DNS lookup.
1661    async fn mock_server_executor() -> (WebScrapeExecutor, wiremock::MockServer) {
1662        let server = wiremock::MockServer::start().await;
1663        let executor = WebScrapeExecutor {
1664            timeout: Duration::from_secs(5),
1665            max_body_bytes: 1_048_576,
1666            allowed_domains: vec![],
1667            denied_domains: vec![],
1668            audit_logger: None,
1669            egress_config: EgressConfig::default(),
1670            egress_tx: None,
1671            egress_dropped: Arc::new(AtomicU64::new(0)),
1672            ipi_filter: IpiFilter::new(0.6),
1673        };
1674        (executor, server)
1675    }
1676
1677    /// Parses the mock server's URI into (`host_str`, `socket_addr`) for use with `build_client`.
1678    fn server_host_and_addr(server: &wiremock::MockServer) -> (String, Vec<std::net::SocketAddr>) {
1679        let uri = server.uri();
1680        let url = Url::parse(&uri).unwrap();
1681        let host = url.host_str().unwrap_or("127.0.0.1").to_owned();
1682        let port = url.port().unwrap_or(80);
1683        let addr: std::net::SocketAddr = format!("{host}:{port}").parse().unwrap();
1684        (host, vec![addr])
1685    }
1686
1687    /// Test-only redirect follower that mimics `fetch_html`'s loop but skips `validate_url` /
1688    /// `resolve_and_validate`. This lets us exercise the redirect-counting and
1689    /// missing-Location logic against a plain HTTP wiremock server.
1690    async fn follow_redirects_raw(
1691        executor: &WebScrapeExecutor,
1692        start_url: &str,
1693        host: &str,
1694        addrs: &[std::net::SocketAddr],
1695    ) -> Result<String, ToolError> {
1696        const MAX_REDIRECTS: usize = 3;
1697        let mut current_url = start_url.to_owned();
1698        let mut current_host = host.to_owned();
1699        let mut current_addrs = addrs.to_vec();
1700
1701        for hop in 0..=MAX_REDIRECTS {
1702            let client = executor.build_client(&current_host, &current_addrs);
1703            let resp = client
1704                .get(&current_url)
1705                .send()
1706                .await
1707                .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1708
1709            let status = resp.status();
1710
1711            if status.is_redirection() {
1712                if hop == MAX_REDIRECTS {
1713                    return Err(ToolError::Execution(std::io::Error::other(
1714                        "too many redirects",
1715                    )));
1716                }
1717
1718                let location = resp
1719                    .headers()
1720                    .get(reqwest::header::LOCATION)
1721                    .and_then(|v| v.to_str().ok())
1722                    .ok_or_else(|| {
1723                        ToolError::Execution(std::io::Error::other("redirect with no Location"))
1724                    })?;
1725
1726                let base = Url::parse(&current_url)
1727                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1728                let next_url = base
1729                    .join(location)
1730                    .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1731
1732                // Re-use same host/addrs (mock server is always the same endpoint).
1733                current_url = next_url.to_string();
1734                // Preserve host/addrs as-is since the mock server doesn't change.
1735                let _ = &mut current_host;
1736                let _ = &mut current_addrs;
1737                continue;
1738            }
1739
1740            if !status.is_success() {
1741                return Err(ToolError::Execution(std::io::Error::other(format!(
1742                    "HTTP {status}",
1743                ))));
1744            }
1745
1746            let bytes = resp
1747                .bytes()
1748                .await
1749                .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())))?;
1750
1751            if bytes.len() > executor.max_body_bytes {
1752                return Err(ToolError::Execution(std::io::Error::other(format!(
1753                    "response too large: {} bytes (max: {})",
1754                    bytes.len(),
1755                    executor.max_body_bytes,
1756                ))));
1757            }
1758
1759            return String::from_utf8(bytes.to_vec())
1760                .map_err(|e| ToolError::Execution(std::io::Error::other(e.to_string())));
1761        }
1762
1763        Err(ToolError::Execution(std::io::Error::other(
1764            "too many redirects",
1765        )))
1766    }
1767
1768    #[tokio::test]
1769    async fn fetch_html_success_returns_body() {
1770        use wiremock::matchers::{method, path};
1771        use wiremock::{Mock, ResponseTemplate};
1772
1773        let (executor, server) = mock_server_executor().await;
1774        Mock::given(method("GET"))
1775            .and(path("/page"))
1776            .respond_with(ResponseTemplate::new(200).set_body_string("<h1>OK</h1>"))
1777            .mount(&server)
1778            .await;
1779
1780        let (host, addrs) = server_host_and_addr(&server);
1781        let url = format!("{}/page", server.uri());
1782        let result = executor
1783            .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1784            .await;
1785        assert!(result.is_ok(), "expected Ok, got: {result:?}");
1786        assert_eq!(result.unwrap(), "<h1>OK</h1>");
1787    }
1788
1789    #[tokio::test]
1790    async fn fetch_html_non_2xx_returns_error() {
1791        use wiremock::matchers::{method, path};
1792        use wiremock::{Mock, ResponseTemplate};
1793
1794        let (executor, server) = mock_server_executor().await;
1795        Mock::given(method("GET"))
1796            .and(path("/forbidden"))
1797            .respond_with(ResponseTemplate::new(403))
1798            .mount(&server)
1799            .await;
1800
1801        let (host, addrs) = server_host_and_addr(&server);
1802        let url = format!("{}/forbidden", server.uri());
1803        let result = executor
1804            .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1805            .await;
1806        assert!(result.is_err());
1807        let msg = result.unwrap_err().to_string();
1808        assert!(msg.contains("403"), "expected 403 in error: {msg}");
1809    }
1810
1811    #[tokio::test]
1812    async fn fetch_html_404_returns_error() {
1813        use wiremock::matchers::{method, path};
1814        use wiremock::{Mock, ResponseTemplate};
1815
1816        let (executor, server) = mock_server_executor().await;
1817        Mock::given(method("GET"))
1818            .and(path("/missing"))
1819            .respond_with(ResponseTemplate::new(404))
1820            .mount(&server)
1821            .await;
1822
1823        let (host, addrs) = server_host_and_addr(&server);
1824        let url = format!("{}/missing", server.uri());
1825        let result = executor
1826            .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1827            .await;
1828        assert!(result.is_err());
1829        let msg = result.unwrap_err().to_string();
1830        assert!(msg.contains("404"), "expected 404 in error: {msg}");
1831    }
1832
1833    #[tokio::test]
1834    async fn fetch_html_redirect_no_location_returns_error() {
1835        use wiremock::matchers::{method, path};
1836        use wiremock::{Mock, ResponseTemplate};
1837
1838        let (executor, server) = mock_server_executor().await;
1839        // 302 with no Location header
1840        Mock::given(method("GET"))
1841            .and(path("/redirect-no-loc"))
1842            .respond_with(ResponseTemplate::new(302))
1843            .mount(&server)
1844            .await;
1845
1846        let (host, addrs) = server_host_and_addr(&server);
1847        let url = format!("{}/redirect-no-loc", server.uri());
1848        let result = executor
1849            .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1850            .await;
1851        assert!(result.is_err());
1852        let msg = result.unwrap_err().to_string();
1853        assert!(
1854            msg.contains("Location") || msg.contains("location"),
1855            "expected Location-related error: {msg}"
1856        );
1857    }
1858
1859    #[tokio::test]
1860    async fn fetch_html_single_redirect_followed() {
1861        use wiremock::matchers::{method, path};
1862        use wiremock::{Mock, ResponseTemplate};
1863
1864        let (executor, server) = mock_server_executor().await;
1865        let final_url = format!("{}/final", server.uri());
1866
1867        Mock::given(method("GET"))
1868            .and(path("/start"))
1869            .respond_with(ResponseTemplate::new(302).insert_header("location", final_url.as_str()))
1870            .mount(&server)
1871            .await;
1872
1873        Mock::given(method("GET"))
1874            .and(path("/final"))
1875            .respond_with(ResponseTemplate::new(200).set_body_string("<p>final</p>"))
1876            .mount(&server)
1877            .await;
1878
1879        let (host, addrs) = server_host_and_addr(&server);
1880        let url = format!("{}/start", server.uri());
1881        let result = follow_redirects_raw(&executor, &url, &host, &addrs).await;
1882        assert!(result.is_ok(), "single redirect should succeed: {result:?}");
1883        assert_eq!(result.unwrap(), "<p>final</p>");
1884    }
1885
1886    #[tokio::test]
1887    async fn fetch_html_three_redirects_allowed() {
1888        use wiremock::matchers::{method, path};
1889        use wiremock::{Mock, ResponseTemplate};
1890
1891        let (executor, server) = mock_server_executor().await;
1892        let hop2 = format!("{}/hop2", server.uri());
1893        let hop3 = format!("{}/hop3", server.uri());
1894        let final_dest = format!("{}/done", server.uri());
1895
1896        Mock::given(method("GET"))
1897            .and(path("/hop1"))
1898            .respond_with(ResponseTemplate::new(301).insert_header("location", hop2.as_str()))
1899            .mount(&server)
1900            .await;
1901        Mock::given(method("GET"))
1902            .and(path("/hop2"))
1903            .respond_with(ResponseTemplate::new(301).insert_header("location", hop3.as_str()))
1904            .mount(&server)
1905            .await;
1906        Mock::given(method("GET"))
1907            .and(path("/hop3"))
1908            .respond_with(ResponseTemplate::new(301).insert_header("location", final_dest.as_str()))
1909            .mount(&server)
1910            .await;
1911        Mock::given(method("GET"))
1912            .and(path("/done"))
1913            .respond_with(ResponseTemplate::new(200).set_body_string("<p>done</p>"))
1914            .mount(&server)
1915            .await;
1916
1917        let (host, addrs) = server_host_and_addr(&server);
1918        let url = format!("{}/hop1", server.uri());
1919        let result = follow_redirects_raw(&executor, &url, &host, &addrs).await;
1920        assert!(result.is_ok(), "3 redirects should succeed: {result:?}");
1921        assert_eq!(result.unwrap(), "<p>done</p>");
1922    }
1923
1924    #[tokio::test]
1925    async fn fetch_html_four_redirects_rejected() {
1926        use wiremock::matchers::{method, path};
1927        use wiremock::{Mock, ResponseTemplate};
1928
1929        let (executor, server) = mock_server_executor().await;
1930        let hop2 = format!("{}/r2", server.uri());
1931        let hop3 = format!("{}/r3", server.uri());
1932        let hop4 = format!("{}/r4", server.uri());
1933        let hop5 = format!("{}/r5", server.uri());
1934
1935        for (from, to) in [
1936            ("/r1", &hop2),
1937            ("/r2", &hop3),
1938            ("/r3", &hop4),
1939            ("/r4", &hop5),
1940        ] {
1941            Mock::given(method("GET"))
1942                .and(path(from))
1943                .respond_with(ResponseTemplate::new(301).insert_header("location", to.as_str()))
1944                .mount(&server)
1945                .await;
1946        }
1947
1948        let (host, addrs) = server_host_and_addr(&server);
1949        let url = format!("{}/r1", server.uri());
1950        let result = follow_redirects_raw(&executor, &url, &host, &addrs).await;
1951        assert!(result.is_err(), "4 redirects should be rejected");
1952        let msg = result.unwrap_err().to_string();
1953        assert!(
1954            msg.contains("redirect"),
1955            "expected redirect-related error: {msg}"
1956        );
1957    }
1958
1959    #[tokio::test]
1960    async fn fetch_html_body_too_large_returns_error() {
1961        use wiremock::matchers::{method, path};
1962        use wiremock::{Mock, ResponseTemplate};
1963
1964        let small_limit_executor = WebScrapeExecutor {
1965            timeout: Duration::from_secs(5),
1966            max_body_bytes: 10,
1967            allowed_domains: vec![],
1968            denied_domains: vec![],
1969            audit_logger: None,
1970            egress_config: EgressConfig::default(),
1971            egress_tx: None,
1972            egress_dropped: Arc::new(AtomicU64::new(0)),
1973            ipi_filter: IpiFilter::new(0.6),
1974        };
1975        let server = wiremock::MockServer::start().await;
1976        Mock::given(method("GET"))
1977            .and(path("/big"))
1978            .respond_with(
1979                ResponseTemplate::new(200)
1980                    .set_body_string("this body is definitely longer than ten bytes"),
1981            )
1982            .mount(&server)
1983            .await;
1984
1985        let (host, addrs) = server_host_and_addr(&server);
1986        let url = format!("{}/big", server.uri());
1987        let result = small_limit_executor
1988            .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
1989            .await;
1990        assert!(result.is_err());
1991        let msg = result.unwrap_err().to_string();
1992        assert!(msg.contains("too large"), "expected too-large error: {msg}");
1993    }
1994
1995    #[test]
1996    fn extract_scrape_blocks_empty_block_content() {
1997        let text = "```scrape\n\n```";
1998        let blocks = extract_scrape_blocks(text);
1999        assert_eq!(blocks.len(), 1);
2000        assert!(blocks[0].is_empty());
2001    }
2002
2003    #[test]
2004    fn extract_scrape_blocks_whitespace_only() {
2005        let text = "```scrape\n   \n```";
2006        let blocks = extract_scrape_blocks(text);
2007        assert_eq!(blocks.len(), 1);
2008    }
2009
2010    #[test]
2011    fn parse_and_extract_multiple_selectors() {
2012        let html = "<div><h1>Title</h1><p>Para</p></div>";
2013        let result = parse_and_extract(html, "h1, p", &ExtractMode::Text, 10).unwrap();
2014        assert!(result.contains("Title"));
2015        assert!(result.contains("Para"));
2016    }
2017
2018    #[test]
2019    fn webscrape_executor_new_with_custom_config() {
2020        let config = ScrapeConfig {
2021            timeout: 60,
2022            max_body_bytes: 512,
2023            ..Default::default()
2024        };
2025        let executor = WebScrapeExecutor::new(&config);
2026        assert_eq!(executor.max_body_bytes, 512);
2027    }
2028
2029    #[test]
2030    fn webscrape_executor_debug() {
2031        let config = ScrapeConfig::default();
2032        let executor = WebScrapeExecutor::new(&config);
2033        let dbg = format!("{executor:?}");
2034        assert!(dbg.contains("WebScrapeExecutor"));
2035    }
2036
2037    #[test]
2038    fn extract_mode_attr_empty_name() {
2039        let mode = ExtractMode::parse("attr:");
2040        assert_matches!(mode, ExtractMode::Attr(ref s) if s.is_empty());
2041    }
2042
2043    #[test]
2044    fn default_extract_returns_text() {
2045        assert_eq!(default_extract(), "text");
2046    }
2047
2048    #[test]
2049    fn scrape_instruction_debug() {
2050        let json = r#"{"url":"https://example.com","select":"h1"}"#;
2051        let instr: ScrapeInstruction = serde_json::from_str(json).unwrap();
2052        let dbg = format!("{instr:?}");
2053        assert!(dbg.contains("ScrapeInstruction"));
2054    }
2055
2056    #[test]
2057    fn extract_mode_debug() {
2058        let mode = ExtractMode::Text;
2059        let dbg = format!("{mode:?}");
2060        assert!(dbg.contains("Text"));
2061    }
2062
2063    // --- fetch_html redirect logic: constant and validation unit tests ---
2064
2065    /// `MAX_REDIRECTS` is 3; the 4th redirect attempt must be rejected.
2066    /// Verify the boundary is correct by inspecting the constant value.
2067    #[test]
2068    fn max_redirects_constant_is_three() {
2069        // fetch_html uses `for hop in 0..=MAX_REDIRECTS` and returns error when hop == MAX_REDIRECTS
2070        // while still in a redirect. That means hops 0,1,2 can redirect; hop 3 triggers the error.
2071        // This test documents the expected limit.
2072        const MAX_REDIRECTS: usize = 3;
2073        assert_eq!(MAX_REDIRECTS, 3, "fetch_html allows exactly 3 redirects");
2074    }
2075
2076    /// Verifies that a Location-less redirect would produce an error string containing the
2077    /// expected message, matching the error path in `fetch_html`.
2078    #[test]
2079    fn redirect_no_location_error_message() {
2080        let err = std::io::Error::other("redirect with no Location");
2081        assert!(err.to_string().contains("redirect with no Location"));
2082    }
2083
2084    /// Verifies that a too-many-redirects condition produces the expected error string.
2085    #[test]
2086    fn too_many_redirects_error_message() {
2087        let err = std::io::Error::other("too many redirects");
2088        assert!(err.to_string().contains("too many redirects"));
2089    }
2090
2091    /// Verifies that a non-2xx HTTP status produces an error message with the status code.
2092    #[test]
2093    fn non_2xx_status_error_format() {
2094        let status = reqwest::StatusCode::FORBIDDEN;
2095        let msg = format!("HTTP {status}");
2096        assert!(msg.contains("403"));
2097    }
2098
2099    /// Verifies that a 404 response status code formats into the expected error message.
2100    #[test]
2101    fn not_found_status_error_format() {
2102        let status = reqwest::StatusCode::NOT_FOUND;
2103        let msg = format!("HTTP {status}");
2104        assert!(msg.contains("404"));
2105    }
2106
2107    /// Verifies relative redirect resolution for same-host paths (simulates Location: /other).
2108    #[test]
2109    fn relative_redirect_same_host_path() {
2110        let base = Url::parse("https://example.com/current").unwrap();
2111        let resolved = base.join("/other").unwrap();
2112        assert_eq!(resolved.as_str(), "https://example.com/other");
2113    }
2114
2115    /// Verifies relative redirect resolution preserves scheme and host.
2116    #[test]
2117    fn relative_redirect_relative_path() {
2118        let base = Url::parse("https://example.com/a/b").unwrap();
2119        let resolved = base.join("c").unwrap();
2120        assert_eq!(resolved.as_str(), "https://example.com/a/c");
2121    }
2122
2123    /// Verifies that an absolute redirect URL overrides base URL completely.
2124    #[test]
2125    fn absolute_redirect_overrides_base() {
2126        let base = Url::parse("https://example.com/page").unwrap();
2127        let resolved = base.join("https://other.com/target").unwrap();
2128        assert_eq!(resolved.as_str(), "https://other.com/target");
2129    }
2130
2131    /// Verifies that a redirect Location of http:// (downgrade) is rejected.
2132    #[test]
2133    fn redirect_http_downgrade_rejected() {
2134        let location = "http://example.com/page";
2135        let base = Url::parse("https://example.com/start").unwrap();
2136        let next = base.join(location).unwrap();
2137        let err = validate_url(next.as_str()).unwrap_err();
2138        assert_matches!(err, ToolError::Blocked { .. });
2139    }
2140
2141    /// Verifies that a redirect to a private IP literal is blocked.
2142    #[test]
2143    fn redirect_location_private_ip_blocked() {
2144        let location = "https://192.168.100.1/admin";
2145        let base = Url::parse("https://example.com/start").unwrap();
2146        let next = base.join(location).unwrap();
2147        let err = validate_url(next.as_str()).unwrap_err();
2148        assert_matches!(err, ToolError::Blocked { .. });
2149        let ToolError::Blocked { command: cmd } = err else {
2150            panic!("expected Blocked");
2151        };
2152        assert!(
2153            cmd.contains("private") || cmd.contains("scheme"),
2154            "error message should describe the block reason: {cmd}"
2155        );
2156    }
2157
2158    /// Verifies that a redirect to a .internal domain is blocked.
2159    #[test]
2160    fn redirect_location_internal_domain_blocked() {
2161        let location = "https://metadata.internal/latest/meta-data/";
2162        let base = Url::parse("https://example.com/start").unwrap();
2163        let next = base.join(location).unwrap();
2164        let err = validate_url(next.as_str()).unwrap_err();
2165        assert_matches!(err, ToolError::Blocked { .. });
2166    }
2167
2168    /// Verifies that a chain of 3 valid public redirects passes `validate_url` at every hop.
2169    #[test]
2170    fn redirect_chain_three_hops_all_public() {
2171        let hops = [
2172            "https://redirect1.example.com/hop1",
2173            "https://redirect2.example.com/hop2",
2174            "https://destination.example.com/final",
2175        ];
2176        for hop in hops {
2177            assert!(validate_url(hop).is_ok(), "expected ok for {hop}");
2178        }
2179    }
2180
2181    // --- SSRF redirect chain defense ---
2182
2183    /// Verifies that a redirect Location pointing to a private IP is rejected by `validate_url`
2184    /// before any connection attempt — simulating the validation step inside `fetch_html`.
2185    #[test]
2186    fn redirect_to_private_ip_rejected_by_validate_url() {
2187        // These would appear as Location headers in a redirect response.
2188        let private_targets = [
2189            "https://127.0.0.1/secret",
2190            "https://10.0.0.1/internal",
2191            "https://192.168.1.1/admin",
2192            "https://172.16.0.1/data",
2193            "https://[::1]/path",
2194            "https://[fe80::1]/path",
2195            "https://localhost/path",
2196            "https://service.internal/api",
2197        ];
2198        for target in private_targets {
2199            let result = validate_url(target);
2200            assert!(result.is_err(), "expected error for {target}");
2201            assert!(
2202                matches!(result.unwrap_err(), ToolError::Blocked { .. }),
2203                "expected Blocked for {target}"
2204            );
2205        }
2206    }
2207
2208    /// Verifies that relative redirect URLs are resolved correctly before validation.
2209    #[test]
2210    fn redirect_relative_url_resolves_correctly() {
2211        let base = Url::parse("https://example.com/page").unwrap();
2212        let relative = "/other";
2213        let resolved = base.join(relative).unwrap();
2214        assert_eq!(resolved.as_str(), "https://example.com/other");
2215    }
2216
2217    /// Verifies that a protocol-relative redirect to http:// is rejected (scheme check).
2218    #[test]
2219    fn redirect_to_http_rejected() {
2220        let err = validate_url("http://example.com/page").unwrap_err();
2221        assert_matches!(err, ToolError::Blocked { .. });
2222    }
2223
2224    #[test]
2225    fn ipv4_mapped_ipv6_link_local_blocked() {
2226        let err = validate_url("https://[::ffff:169.254.0.1]/path").unwrap_err();
2227        assert_matches!(err, ToolError::Blocked { .. });
2228    }
2229
2230    #[test]
2231    fn ipv4_mapped_ipv6_public_allowed() {
2232        assert!(validate_url("https://[::ffff:93.184.216.34]/path").is_ok());
2233    }
2234
2235    // --- fetch tool ---
2236
2237    #[tokio::test]
2238    async fn fetch_http_scheme_blocked() {
2239        let config = ScrapeConfig::default();
2240        let executor = WebScrapeExecutor::new(&config);
2241        let call = crate::executor::ToolCall {
2242            tool_id: ToolName::new("fetch"),
2243            params: {
2244                let mut m = serde_json::Map::new();
2245                m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2246                m
2247            },
2248            caller_id: None,
2249            context: None,
2250
2251            tool_call_id: String::new(),
2252            skill_name: None,
2253        };
2254        let result = executor.execute_tool_call(&call).await;
2255        assert_matches!(result, Err(ToolError::Blocked { .. }));
2256    }
2257
2258    #[tokio::test]
2259    async fn fetch_private_ip_blocked() {
2260        let config = ScrapeConfig::default();
2261        let executor = WebScrapeExecutor::new(&config);
2262        let call = crate::executor::ToolCall {
2263            tool_id: ToolName::new("fetch"),
2264            params: {
2265                let mut m = serde_json::Map::new();
2266                m.insert(
2267                    "url".to_owned(),
2268                    serde_json::json!("https://192.168.1.1/secret"),
2269                );
2270                m
2271            },
2272            caller_id: None,
2273            context: None,
2274
2275            tool_call_id: String::new(),
2276            skill_name: None,
2277        };
2278        let result = executor.execute_tool_call(&call).await;
2279        assert_matches!(result, Err(ToolError::Blocked { .. }));
2280    }
2281
2282    #[tokio::test]
2283    async fn fetch_localhost_blocked() {
2284        let config = ScrapeConfig::default();
2285        let executor = WebScrapeExecutor::new(&config);
2286        let call = crate::executor::ToolCall {
2287            tool_id: ToolName::new("fetch"),
2288            params: {
2289                let mut m = serde_json::Map::new();
2290                m.insert(
2291                    "url".to_owned(),
2292                    serde_json::json!("https://localhost/page"),
2293                );
2294                m
2295            },
2296            caller_id: None,
2297            context: None,
2298
2299            tool_call_id: String::new(),
2300            skill_name: None,
2301        };
2302        let result = executor.execute_tool_call(&call).await;
2303        assert_matches!(result, Err(ToolError::Blocked { .. }));
2304    }
2305
2306    #[tokio::test]
2307    async fn fetch_unknown_tool_returns_none() {
2308        let config = ScrapeConfig::default();
2309        let executor = WebScrapeExecutor::new(&config);
2310        let call = crate::executor::ToolCall {
2311            tool_id: ToolName::new("unknown_tool"),
2312            params: serde_json::Map::new(),
2313            caller_id: None,
2314            context: None,
2315
2316            tool_call_id: String::new(),
2317            skill_name: None,
2318        };
2319        let result = executor.execute_tool_call(&call).await;
2320        assert!(result.unwrap().is_none());
2321    }
2322
2323    #[tokio::test]
2324    async fn fetch_returns_body_via_mock() {
2325        use wiremock::matchers::{method, path};
2326        use wiremock::{Mock, ResponseTemplate};
2327
2328        let (executor, server) = mock_server_executor().await;
2329        Mock::given(method("GET"))
2330            .and(path("/content"))
2331            .respond_with(ResponseTemplate::new(200).set_body_string("plain text content"))
2332            .mount(&server)
2333            .await;
2334
2335        let (host, addrs) = server_host_and_addr(&server);
2336        let url = format!("{}/content", server.uri());
2337        let result = executor
2338            .fetch_html(&url, &host, &addrs, "fetch", "test-cid", None, None)
2339            .await;
2340        assert!(result.is_ok());
2341        assert_eq!(result.unwrap(), "plain text content");
2342    }
2343
2344    #[test]
2345    fn tool_definitions_returns_web_scrape_and_fetch() {
2346        let config = ScrapeConfig::default();
2347        let executor = WebScrapeExecutor::new(&config);
2348        let defs = executor.tool_definitions();
2349        assert_eq!(defs.len(), 2);
2350        assert_eq!(defs[0].id, "web_scrape");
2351        assert_eq!(
2352            defs[0].invocation,
2353            crate::registry::InvocationHint::FencedBlock("scrape")
2354        );
2355        assert_eq!(defs[1].id, "fetch");
2356        assert_eq!(
2357            defs[1].invocation,
2358            crate::registry::InvocationHint::ToolCall
2359        );
2360    }
2361
2362    #[test]
2363    fn tool_definitions_schema_has_all_params() {
2364        let config = ScrapeConfig::default();
2365        let executor = WebScrapeExecutor::new(&config);
2366        let defs = executor.tool_definitions();
2367        let obj = defs[0].schema.as_object().unwrap();
2368        let props = obj["properties"].as_object().unwrap();
2369        assert!(props.contains_key("url"));
2370        assert!(props.contains_key("select"));
2371        assert!(props.contains_key("extract"));
2372        assert!(props.contains_key("limit"));
2373        let req = obj["required"].as_array().unwrap();
2374        assert!(req.iter().any(|v| v.as_str() == Some("url")));
2375        assert!(req.iter().any(|v| v.as_str() == Some("select")));
2376        assert!(!req.iter().any(|v| v.as_str() == Some("extract")));
2377    }
2378
2379    // --- is_private_host: new domain checks (AUD-02) ---
2380
2381    #[test]
2382    fn subdomain_localhost_blocked() {
2383        let host: url::Host<&str> = url::Host::Domain("foo.localhost");
2384        assert!(is_private_host(&host));
2385    }
2386
2387    #[test]
2388    fn internal_tld_blocked() {
2389        let host: url::Host<&str> = url::Host::Domain("service.internal");
2390        assert!(is_private_host(&host));
2391    }
2392
2393    #[test]
2394    fn local_tld_blocked() {
2395        let host: url::Host<&str> = url::Host::Domain("printer.local");
2396        assert!(is_private_host(&host));
2397    }
2398
2399    #[test]
2400    fn public_domain_not_blocked() {
2401        let host: url::Host<&str> = url::Host::Domain("example.com");
2402        assert!(!is_private_host(&host));
2403    }
2404
2405    // --- resolve_and_validate: private IP rejection ---
2406
2407    #[tokio::test]
2408    async fn resolve_loopback_rejected() {
2409        // 127.0.0.1 resolves directly (literal IP in DNS query)
2410        let url = url::Url::parse("https://127.0.0.1/path").unwrap();
2411        // validate_url catches this before resolve_and_validate, but test directly
2412        let result = resolve_and_validate(&url).await;
2413        assert!(
2414            result.is_err(),
2415            "loopback IP must be rejected by resolve_and_validate"
2416        );
2417        let err = result.unwrap_err();
2418        assert_matches!(err, crate::executor::ToolError::Blocked { .. });
2419    }
2420
2421    #[tokio::test]
2422    async fn resolve_private_10_rejected() {
2423        let url = url::Url::parse("https://10.0.0.1/path").unwrap();
2424        let result = resolve_and_validate(&url).await;
2425        assert!(result.is_err());
2426        assert_matches!(
2427            result.unwrap_err(),
2428            crate::executor::ToolError::Blocked { .. }
2429        );
2430    }
2431
2432    #[tokio::test]
2433    async fn resolve_private_192_rejected() {
2434        let url = url::Url::parse("https://192.168.1.1/path").unwrap();
2435        let result = resolve_and_validate(&url).await;
2436        assert!(result.is_err());
2437        assert_matches!(
2438            result.unwrap_err(),
2439            crate::executor::ToolError::Blocked { .. }
2440        );
2441    }
2442
2443    #[tokio::test]
2444    async fn resolve_ipv6_loopback_rejected() {
2445        let url = url::Url::parse("https://[::1]/path").unwrap();
2446        let result = resolve_and_validate(&url).await;
2447        assert!(result.is_err());
2448        assert_matches!(
2449            result.unwrap_err(),
2450            crate::executor::ToolError::Blocked { .. }
2451        );
2452    }
2453
2454    #[tokio::test]
2455    async fn resolve_no_host_returns_ok() {
2456        // URL without a resolvable host — should pass through
2457        let url = url::Url::parse("https://example.com/path").unwrap();
2458        // We can't do a live DNS test, but we can verify a URL with no host
2459        let url_no_host = url::Url::parse("data:text/plain,hello").unwrap();
2460        // data: URLs have no host; resolve_and_validate should return Ok with empty addrs
2461        let result = resolve_and_validate(&url_no_host).await;
2462        assert!(result.is_ok());
2463        let (host, addrs) = result.unwrap();
2464        assert!(host.is_empty());
2465        assert!(addrs.is_empty());
2466        drop(url);
2467        drop(url_no_host);
2468    }
2469
2470    // --- audit logging ---
2471
2472    /// Helper: build an `AuditLogger` writing to a temp file, and return the logger + path.
2473    async fn make_file_audit_logger(
2474        dir: &tempfile::TempDir,
2475    ) -> (
2476        std::sync::Arc<crate::audit::AuditLogger>,
2477        std::path::PathBuf,
2478    ) {
2479        use crate::audit::AuditLogger;
2480        use crate::config::AuditConfig;
2481        let path = dir.path().join("audit.log");
2482        let config = AuditConfig {
2483            enabled: true,
2484            destination: crate::config::AuditDestination::File(path.clone()),
2485            ..Default::default()
2486        };
2487        let logger = std::sync::Arc::new(AuditLogger::from_config(&config, false).await.unwrap());
2488        (logger, path)
2489    }
2490
2491    #[tokio::test]
2492    async fn with_audit_sets_logger() {
2493        let config = ScrapeConfig::default();
2494        let executor = WebScrapeExecutor::new(&config);
2495        assert!(executor.audit_logger.is_none());
2496
2497        let dir = tempfile::tempdir().unwrap();
2498        let (logger, _path) = make_file_audit_logger(&dir).await;
2499        let executor = executor.with_audit(logger);
2500        assert!(executor.audit_logger.is_some());
2501    }
2502
2503    #[test]
2504    fn tool_error_to_audit_result_blocked_maps_correctly() {
2505        let err = ToolError::Blocked {
2506            command: "scheme not allowed: http".into(),
2507        };
2508        let result = tool_error_to_audit_result(&err);
2509        assert!(
2510            matches!(result, AuditResult::Blocked { reason } if reason == "scheme not allowed: http")
2511        );
2512    }
2513
2514    #[test]
2515    fn tool_error_to_audit_result_timeout_maps_correctly() {
2516        let err = ToolError::Timeout { timeout_secs: 15 };
2517        let result = tool_error_to_audit_result(&err);
2518        assert_matches!(result, AuditResult::Timeout);
2519    }
2520
2521    #[test]
2522    fn tool_error_to_audit_result_execution_error_maps_correctly() {
2523        let err = ToolError::Execution(std::io::Error::other("connection refused"));
2524        let result = tool_error_to_audit_result(&err);
2525        assert!(
2526            matches!(result, AuditResult::Error { message } if message.contains("connection refused"))
2527        );
2528    }
2529
2530    #[tokio::test]
2531    async fn fetch_audit_blocked_url_logged() {
2532        let dir = tempfile::tempdir().unwrap();
2533        let (logger, log_path) = make_file_audit_logger(&dir).await;
2534
2535        let config = ScrapeConfig::default();
2536        let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2537
2538        let call = crate::executor::ToolCall {
2539            tool_id: ToolName::new("fetch"),
2540            params: {
2541                let mut m = serde_json::Map::new();
2542                m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2543                m
2544            },
2545            caller_id: None,
2546            context: None,
2547
2548            tool_call_id: String::new(),
2549            skill_name: None,
2550        };
2551        let result = executor.execute_tool_call(&call).await;
2552        assert_matches!(result, Err(ToolError::Blocked { .. }));
2553
2554        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2555        assert!(
2556            content.contains("\"tool\":\"fetch\""),
2557            "expected tool=fetch in audit: {content}"
2558        );
2559        assert!(
2560            content.contains("\"type\":\"blocked\""),
2561            "expected type=blocked in audit: {content}"
2562        );
2563        assert!(
2564            content.contains("http://example.com"),
2565            "expected URL in audit command field: {content}"
2566        );
2567    }
2568
2569    #[tokio::test]
2570    async fn log_audit_success_writes_to_file() {
2571        let dir = tempfile::tempdir().unwrap();
2572        let (logger, log_path) = make_file_audit_logger(&dir).await;
2573
2574        let config = ScrapeConfig::default();
2575        let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2576
2577        executor
2578            .log_audit(
2579                "fetch",
2580                "https://example.com/page",
2581                AuditResult::Success,
2582                42,
2583                None,
2584                None,
2585                None,
2586                None,
2587            )
2588            .await;
2589
2590        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2591        assert!(
2592            content.contains("\"tool\":\"fetch\""),
2593            "expected tool=fetch in audit: {content}"
2594        );
2595        assert!(
2596            content.contains("\"type\":\"success\""),
2597            "expected type=success in audit: {content}"
2598        );
2599        assert!(
2600            content.contains("\"command\":\"https://example.com/page\""),
2601            "expected command URL in audit: {content}"
2602        );
2603        assert!(
2604            content.contains("\"duration_ms\":42"),
2605            "expected duration_ms in audit: {content}"
2606        );
2607    }
2608
2609    #[tokio::test]
2610    async fn log_audit_blocked_writes_to_file() {
2611        let dir = tempfile::tempdir().unwrap();
2612        let (logger, log_path) = make_file_audit_logger(&dir).await;
2613
2614        let config = ScrapeConfig::default();
2615        let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2616
2617        executor
2618            .log_audit(
2619                "web_scrape",
2620                "http://evil.com/page",
2621                AuditResult::Blocked {
2622                    reason: "scheme not allowed: http".into(),
2623                },
2624                0,
2625                None,
2626                None,
2627                None,
2628                None,
2629            )
2630            .await;
2631
2632        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2633        assert!(
2634            content.contains("\"tool\":\"web_scrape\""),
2635            "expected tool=web_scrape in audit: {content}"
2636        );
2637        assert!(
2638            content.contains("\"type\":\"blocked\""),
2639            "expected type=blocked in audit: {content}"
2640        );
2641        assert!(
2642            content.contains("scheme not allowed"),
2643            "expected block reason in audit: {content}"
2644        );
2645    }
2646
2647    #[tokio::test]
2648    async fn web_scrape_audit_blocked_url_logged() {
2649        let dir = tempfile::tempdir().unwrap();
2650        let (logger, log_path) = make_file_audit_logger(&dir).await;
2651
2652        let config = ScrapeConfig::default();
2653        let executor = WebScrapeExecutor::new(&config).with_audit(logger);
2654
2655        let call = crate::executor::ToolCall {
2656            tool_id: ToolName::new("web_scrape"),
2657            params: {
2658                let mut m = serde_json::Map::new();
2659                m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2660                m.insert("select".to_owned(), serde_json::json!("h1"));
2661                m
2662            },
2663            caller_id: None,
2664            context: None,
2665
2666            tool_call_id: String::new(),
2667            skill_name: None,
2668        };
2669        let result = executor.execute_tool_call(&call).await;
2670        assert_matches!(result, Err(ToolError::Blocked { .. }));
2671
2672        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
2673        assert!(
2674            content.contains("\"tool\":\"web_scrape\""),
2675            "expected tool=web_scrape in audit: {content}"
2676        );
2677        assert!(
2678            content.contains("\"type\":\"blocked\""),
2679            "expected type=blocked in audit: {content}"
2680        );
2681    }
2682
2683    #[tokio::test]
2684    async fn no_audit_logger_does_not_panic_on_blocked_fetch() {
2685        let config = ScrapeConfig::default();
2686        let executor = WebScrapeExecutor::new(&config);
2687        assert!(executor.audit_logger.is_none());
2688
2689        let call = crate::executor::ToolCall {
2690            tool_id: ToolName::new("fetch"),
2691            params: {
2692                let mut m = serde_json::Map::new();
2693                m.insert("url".to_owned(), serde_json::json!("http://example.com"));
2694                m
2695            },
2696            caller_id: None,
2697            context: None,
2698
2699            tool_call_id: String::new(),
2700            skill_name: None,
2701        };
2702        // Must not panic even without an audit logger
2703        let result = executor.execute_tool_call(&call).await;
2704        assert_matches!(result, Err(ToolError::Blocked { .. }));
2705    }
2706
2707    // CR-10: fetch end-to-end via execute_tool_call -> handle_fetch -> fetch_html
2708    #[tokio::test]
2709    async fn fetch_execute_tool_call_end_to_end() {
2710        use wiremock::matchers::{method, path};
2711        use wiremock::{Mock, ResponseTemplate};
2712
2713        let (executor, server) = mock_server_executor().await;
2714        Mock::given(method("GET"))
2715            .and(path("/e2e"))
2716            .respond_with(ResponseTemplate::new(200).set_body_string("<h1>end-to-end</h1>"))
2717            .mount(&server)
2718            .await;
2719
2720        let (host, addrs) = server_host_and_addr(&server);
2721        // Call fetch_html directly (bypassing SSRF guard for loopback mock server)
2722        let result = executor
2723            .fetch_html(
2724                &format!("{}/e2e", server.uri()),
2725                &host,
2726                &addrs,
2727                "fetch",
2728                "test-cid",
2729                None,
2730                None,
2731            )
2732            .await;
2733        assert!(result.is_ok());
2734        assert!(result.unwrap().contains("end-to-end"));
2735    }
2736
2737    // --- domain_matches ---
2738
2739    #[test]
2740    fn domain_matches_exact() {
2741        assert!(domain_matches("example.com", "example.com"));
2742        assert!(!domain_matches("example.com", "other.com"));
2743        assert!(!domain_matches("example.com", "sub.example.com"));
2744    }
2745
2746    #[test]
2747    fn domain_matches_wildcard_single_subdomain() {
2748        assert!(domain_matches("*.example.com", "sub.example.com"));
2749        assert!(!domain_matches("*.example.com", "example.com"));
2750        assert!(!domain_matches("*.example.com", "sub.sub.example.com"));
2751    }
2752
2753    #[test]
2754    fn domain_matches_wildcard_does_not_match_empty_label() {
2755        // Pattern "*.example.com" requires a non-empty label before ".example.com"
2756        assert!(!domain_matches("*.example.com", ".example.com"));
2757    }
2758
2759    #[test]
2760    fn domain_matches_multi_wildcard_treated_as_exact() {
2761        // Multiple wildcards are unsupported — treated as literal pattern
2762        assert!(!domain_matches("*.*.example.com", "a.b.example.com"));
2763    }
2764
2765    // --- check_domain_policy ---
2766
2767    #[test]
2768    fn check_domain_policy_empty_lists_allow_all() {
2769        assert!(check_domain_policy("example.com", &[], &[]).is_ok());
2770        assert!(check_domain_policy("evil.com", &[], &[]).is_ok());
2771    }
2772
2773    #[test]
2774    fn check_domain_policy_denylist_blocks() {
2775        let denied = vec!["evil.com".to_string()];
2776        let err = check_domain_policy("evil.com", &[], &denied).unwrap_err();
2777        assert_matches!(err, ToolError::Blocked { .. });
2778    }
2779
2780    #[test]
2781    fn check_domain_policy_denylist_does_not_block_other_domains() {
2782        let denied = vec!["evil.com".to_string()];
2783        assert!(check_domain_policy("good.com", &[], &denied).is_ok());
2784    }
2785
2786    #[test]
2787    fn check_domain_policy_allowlist_permits_matching() {
2788        let allowed = vec!["docs.rs".to_string(), "*.rust-lang.org".to_string()];
2789        assert!(check_domain_policy("docs.rs", &allowed, &[]).is_ok());
2790        assert!(check_domain_policy("blog.rust-lang.org", &allowed, &[]).is_ok());
2791    }
2792
2793    #[test]
2794    fn check_domain_policy_allowlist_blocks_unknown() {
2795        let allowed = vec!["docs.rs".to_string()];
2796        let err = check_domain_policy("other.com", &allowed, &[]).unwrap_err();
2797        assert_matches!(err, ToolError::Blocked { .. });
2798    }
2799
2800    #[test]
2801    fn check_domain_policy_deny_overrides_allow() {
2802        let allowed = vec!["example.com".to_string()];
2803        let denied = vec!["example.com".to_string()];
2804        let err = check_domain_policy("example.com", &allowed, &denied).unwrap_err();
2805        assert_matches!(err, ToolError::Blocked { .. });
2806    }
2807
2808    #[test]
2809    fn check_domain_policy_wildcard_in_denylist() {
2810        let denied = vec!["*.evil.com".to_string()];
2811        let err = check_domain_policy("sub.evil.com", &[], &denied).unwrap_err();
2812        assert_matches!(err, ToolError::Blocked { .. });
2813        // parent domain not blocked
2814        assert!(check_domain_policy("evil.com", &[], &denied).is_ok());
2815    }
2816}