Skip to main content

crw_server/
diagnostics.rs

1//! Operator-facing diagnostics helpers (issue #90).
2//!
3//! `searxng_url` is operator-set and can carry secrets (`https://user:pass@host`,
4//! a `?token=…`, or a token embedded in the path of a reverse-proxy URL). Anything
5//! we log or return in an error must be sanitized to the bare origin first.
6
7use crw_core::config::SearchConfig;
8use tracing::Level;
9
10/// Reduce a URL to its **origin** — `scheme://host[:port]` — dropping userinfo,
11/// path, query, and fragment. This is the only form safe to log or echo in an
12/// error, because every other component can carry a secret. Falls back to a
13/// fixed redaction string if the URL doesn't parse.
14pub fn sanitize_url_origin(raw: &str) -> String {
15    match url::Url::parse(raw) {
16        Ok(u) => match (u.host_str(), u.port()) {
17            (Some(host), Some(port)) => format!("{}://{host}:{port}", u.scheme()),
18            (Some(host), None) => format!("{}://{host}", u.scheme()),
19            (None, _) => "<redacted-url>".to_string(),
20        },
21        Err(_) => "<redacted-url>".to_string(),
22    }
23}
24
25/// One-line summary of the search subsystem's configured state, for the startup
26/// log. Distinguishes the three states that otherwise collapse to a single
27/// "search disabled" at request time:
28///   - `enabled = false`            → intentionally off
29///   - enabled, `searxng_url` unset → misconfigured (every call will 503)
30///   - enabled, `searxng_url` set   → active (host shown, origin-sanitized)
31pub fn search_startup_status(cfg: &SearchConfig) -> (Level, String) {
32    if !cfg.enabled {
33        (
34            Level::INFO,
35            "search: disabled ([search].enabled = false)".to_string(),
36        )
37    } else if let Some(url) = &cfg.searxng_url {
38        (
39            Level::INFO,
40            format!("search: enabled (searxng={})", sanitize_url_origin(url)),
41        )
42    } else {
43        (
44            Level::WARN,
45            "search: enabled but no [search].searxng_url — /v1/search will return 503".to_string(),
46        )
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn sanitize_strips_userinfo_path_query_fragment() {
56        assert_eq!(
57            sanitize_url_origin("https://user:pass@host:9000/searxng/tok123?q=x#frag"),
58            "https://host:9000"
59        );
60        assert_eq!(
61            sanitize_url_origin("http://searxng:8080"),
62            "http://searxng:8080"
63        );
64        assert_eq!(
65            sanitize_url_origin("http://searxng:8080/"),
66            "http://searxng:8080"
67        );
68        // Default port is preserved as written only if explicit; no port → no port.
69        assert_eq!(
70            sanitize_url_origin("https://example.com"),
71            "https://example.com"
72        );
73    }
74
75    #[test]
76    fn sanitize_redacts_unparseable() {
77        assert_eq!(sanitize_url_origin("not a url"), "<redacted-url>");
78    }
79
80    fn cfg(enabled: bool, url: Option<&str>) -> SearchConfig {
81        let toml = match url {
82            Some(u) => format!("enabled = {enabled}\nsearxng_url = \"{u}\"\n"),
83            None => format!("enabled = {enabled}\n"),
84        };
85        toml::from_str(&toml).expect("valid SearchConfig")
86    }
87
88    #[test]
89    fn startup_status_enabled_with_url() {
90        let (level, msg) = search_startup_status(&cfg(true, Some("http://searxng:8080")));
91        assert_eq!(level, Level::INFO);
92        assert!(
93            msg.contains("enabled (searxng=http://searxng:8080)"),
94            "{msg}"
95        );
96    }
97
98    #[test]
99    fn startup_status_enabled_no_url_warns() {
100        let (level, msg) = search_startup_status(&cfg(true, None));
101        assert_eq!(level, Level::WARN);
102        assert!(msg.contains("no [search].searxng_url"), "{msg}");
103    }
104
105    #[test]
106    fn startup_status_disabled() {
107        let (level, msg) = search_startup_status(&cfg(false, Some("http://searxng:8080")));
108        assert_eq!(level, Level::INFO);
109        assert!(msg.contains("disabled"), "{msg}");
110    }
111
112    #[test]
113    fn startup_status_never_leaks_credentials() {
114        let (_, msg) = search_startup_status(&cfg(true, Some("https://u:secret@host:8080/tok")));
115        assert!(!msg.contains("secret"), "{msg}");
116        assert!(!msg.contains("tok"), "{msg}");
117        assert!(msg.contains("https://host:8080"), "{msg}");
118    }
119}