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