From 2110b5182217d52cea8a774edcf6f2b166a42885 Mon Sep 17 00:00:00 2001 From: s0u7a Date: Mon, 9 Nov 2026 23:34:06 +0000 Subject: [PATCH] fix: validate timeout_limit at the HTTP entry point instead of panicking on inf --- src/aggregator.rs | 66 +++++++++++++++++++++++++++++++++++++++++---- src/settings.rs | 26 ++++++++++++++++++ src/web/handlers.rs | 18 ++++++++++++- 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/aggregator.rs b/src/aggregator.rs index 60cc578..b443e09 100644 --- a/src/aggregator.rs +++ b/src/aggregator.rs @@ -18,6 +18,28 @@ use crate::query::{Query, TimeRange}; use crate::results::{Infobox, ResultContainer, SearchResult, Timing, UnresponsiveEngine}; use crate::settings::Settings; +/// Largest per-request `timeout_limit`, in seconds, that the HTTP layer +/// accepts. The default search deadline is 3.0 seconds; 60 seconds matches +/// the largest timeout already present in the settings (the research and +/// LLM timeouts) and leaves ample headroom without letting one request +/// hold a search open indefinitely. +pub(crate) const MAX_TIMEOUT_LIMIT: f64 = 60.0; + +/// Whether a caller-supplied `timeout_limit` (in seconds) may be honoured: +/// finite, positive and within the server cap. +pub(crate) fn is_valid_timeout_limit(value: f64) -> bool { + value.is_finite() && value > 0.0 && value <= MAX_TIMEOUT_LIMIT +} + +/// Resolve the effective search timeout in seconds. A non-finite or +/// non-positive override — reachable through the library API, which +/// bypasses the HTTP validation — falls back to the configured limit. +fn resolve_timeout_limit(requested: Option, configured: f64) -> f64 { + requested + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(configured) +} + /// Everything the searcher needs to know about one search request. #[derive(Debug, Clone)] pub struct SearchRequest { @@ -610,13 +632,14 @@ impl Searcher { engines.retain(|engine| engine.is_applicable(&request.query)); // All providers share one deadline; completed results are retained. + // `try_from_secs_f64` keeps an unrepresentable configured value + // (settings validation normally rejects those) from panicking here. let deadline = Instant::now() - + Duration::from_secs_f64( - request - .timeout_limit - .unwrap_or(self.settings.search.timeout_limit) + + Duration::try_from_secs_f64( + resolve_timeout_limit(request.timeout_limit, self.settings.search.timeout_limit) .max(0.1), - ); + ) + .unwrap_or(Duration::from_millis(100)); // A general search that names a specialised source ("... arxiv", // "... github") also asks that source, without the site's name. let routed = self.site_routes(&request, &engines); @@ -2373,6 +2396,39 @@ mod tests { use super::*; + #[test] + fn timeout_limit_validation_rejects_non_finite_and_out_of_range_values() { + assert!(is_valid_timeout_limit(3.0)); + assert!(is_valid_timeout_limit(MAX_TIMEOUT_LIMIT)); + for value in [ + f64::INFINITY, + f64::NEG_INFINITY, + f64::NAN, + 0.0, + -1.0, + MAX_TIMEOUT_LIMIT + 0.1, + 1e300, + ] { + assert!( + !is_valid_timeout_limit(value), + "timeout_limit {value} must be rejected" + ); + } + } + + #[test] + fn invalid_timeout_limit_overrides_fall_back_to_the_configured_value() { + assert_eq!(resolve_timeout_limit(Some(2.5), 3.0), 2.5); + assert_eq!(resolve_timeout_limit(None, 3.0), 3.0); + for value in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 0.0, -1.0] { + assert_eq!( + resolve_timeout_limit(Some(value), 3.0), + 3.0, + "timeout_limit {value} must fall back" + ); + } + } + #[test] fn cosine_similarity_behaves_as_expected() { assert!((dot(&[1.0f32, 0.0], &[1.0f32, 0.0]) - 1.0).abs() < 1e-6); diff --git a/src/settings.rs b/src/settings.rs index 7565290..62cdeec 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1048,6 +1048,14 @@ impl Settings { "fetch.timeout must be a finite positive duration".into(), )); } + if !self.search.timeout_limit.is_finite() + || self.search.timeout_limit <= 0.0 + || std::time::Duration::try_from_secs_f64(self.search.timeout_limit).is_err() + { + return Err(SettingsError::Validation( + "search.timeout_limit must be a finite positive duration".into(), + )); + } if self.outgoing.request_timeout <= 0.0 || self.outgoing.max_request_timeout <= 0.0 { return Err(SettingsError::Validation( "outgoing timeouts must be positive".into(), @@ -1963,6 +1971,24 @@ engines: } } + #[test] + fn search_timeout_limit_must_be_finite_and_representable() { + for (name, value) in [ + ("zero", "0.0"), + ("negative", "-1.0"), + ("infinity", "inf"), + ("negative infinity", "-inf"), + ("not a number", "nan"), + ("out of range", "1e300"), + ] { + let settings = merged_toml(&format!("[search]\ntimeout_limit = {value}\n")); + assert!( + settings.validate().is_err(), + "search.timeout_limit = {value} ({name}) must be rejected" + ); + } + } + #[test] fn deepseek_engine_is_disabled_by_default() { let settings = Settings::defaults(); diff --git a/src/web/handlers.rs b/src/web/handlers.rs index 7cba010..ffd0794 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers.rs @@ -223,7 +223,23 @@ async fn run_search( query.pageno = params.pageno.unwrap_or(1).max(1); query.time_range = params.time_range.as_deref().and_then(TimeRange::parse); query.safesearch = params.safesearch.unwrap_or(preferences.safesearch).min(2); - query.timeout_limit = params.timeout_limit; + if let Some(timeout_limit) = params.timeout_limit { + if !crate::aggregator::is_valid_timeout_limit(timeout_limit) { + let message = format!( + "timeout_limit must be a finite number of seconds greater than 0 and at most {}", + crate::aggregator::MAX_TIMEOUT_LIMIT + ); + if format == "json" { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": message})), + ) + .into_response(); + } + return error_page(&state, StatusCode::BAD_REQUEST, &message); + } + query.timeout_limit = Some(timeout_limit); + } let categories = split_param(params.categories.as_deref()) .or_else(|| split_param(params.category.as_deref()))