Skip to main content

harness_web/
config-validate.rs

1//! The configuration error and the validators behind
2//! [`FetchConfigBuilder::build`](super::FetchConfigBuilder::build).
3//!
4//! Each validator checks one raw builder field against its constraint and
5//! returns the validated newtype the policy stores, or the private error
6//! representation naming the field and the violated constraint. The public
7//! [`ConfigError`] wraps that representation opaquely.
8
9use std::net::IpAddr;
10use std::time::Duration;
11
12use ipnet::IpNet;
13use reqwest::header::HeaderValue;
14
15use super::{HostAddressException, MAX_REDIRECTS_CEILING, MaxRedirects, UserAgent, canonical_host};
16
17/// The error reported when a fetch configuration fails validation or its HTTP
18/// client fails to build.
19///
20/// When a field fails validation, the [`Display`] message names the field and
21/// the constraint it violated.
22///
23/// [`Display`]: std::fmt::Display
24#[derive(Debug, thiserror::Error)]
25#[error(transparent)]
26pub struct ConfigError(#[from] ConfigErrorRepr);
27
28/// The private representation behind [`ConfigError`].
29#[derive(Debug, thiserror::Error)]
30pub(super) enum ConfigErrorRepr {
31    /// The user agent is not a legal HTTP header value.
32    #[error("user agent is not a valid http header value")]
33    UserAgent(#[source] reqwest::header::InvalidHeaderValue),
34
35    /// A limit was zero, which would disable the bound it governs.
36    #[error("{field} must be greater than zero")]
37    ZeroLimit {
38        /// The name of the offending limit.
39        field: &'static str,
40    },
41
42    /// A limit exceeded its hard operational ceiling.
43    #[error("{field} ({value}) exceeds the maximum of {ceiling}")]
44    OverCeiling {
45        /// The name of the offending limit.
46        field: &'static str,
47        /// The rejected value.
48        value: usize,
49        /// The ceiling it exceeded.
50        ceiling: usize,
51    },
52
53    /// A timeout was zero, which the policy does not allow.
54    #[error("{field} must be a positive duration")]
55    ZeroTimeout {
56        /// The name of the offending timeout.
57        field: &'static str,
58    },
59
60    /// A timeout exceeded its hard operational ceiling.
61    #[error("{field} ({value:?}) exceeds the maximum of {ceiling:?}")]
62    TimeoutOverCeiling {
63        /// The name of the offending timeout.
64        field: &'static str,
65        /// The rejected duration.
66        value: Duration,
67        /// The ceiling it exceeded.
68        ceiling: Duration,
69    },
70
71    /// A denied-CIDR string did not parse.
72    #[error("invalid deny cidr {cidr}")]
73    Cidr {
74        /// The rejected CIDR text.
75        cidr: String,
76        /// The parse failure.
77        #[source]
78        source: ipnet::AddrParseError,
79    },
80
81    /// An exact-host exception named an empty or malformed host.
82    #[error("invalid exact host {host:?}")]
83    Host {
84        /// The rejected host text.
85        host: String,
86    },
87
88    /// The HTTP client could not be built for the validated policy.
89    #[error("http client construction failed")]
90    ClientBuild(#[source] reqwest::Error),
91}
92
93impl ConfigError {
94    /// Builds a `ConfigError` from a reqwest client-build failure.
95    pub(crate) fn client_build(source: reqwest::Error) -> ConfigError {
96        ConfigError(ConfigErrorRepr::ClientBuild(source))
97    }
98}
99
100/// Validates a `User-Agent` string as a legal HTTP header value.
101pub(super) fn validate_user_agent(ua: String) -> Result<UserAgent, ConfigErrorRepr> {
102    HeaderValue::from_str(&ua).map_err(ConfigErrorRepr::UserAgent)?;
103    Ok(UserAgent(ua))
104}
105
106/// Validates a positive limit against a hard ceiling.
107pub(super) fn validate_limit(
108    field: &'static str,
109    value: usize,
110    ceiling: usize,
111) -> Result<usize, ConfigErrorRepr> {
112    if value == 0 {
113        return Err(ConfigErrorRepr::ZeroLimit { field });
114    }
115    if value > ceiling {
116        return Err(ConfigErrorRepr::OverCeiling {
117            field,
118            value,
119            ceiling,
120        });
121    }
122    Ok(value)
123}
124
125/// Validates the redirect cap against its ceiling; zero is permitted.
126pub(super) fn validate_redirects(value: usize) -> Result<MaxRedirects, ConfigErrorRepr> {
127    if value > MAX_REDIRECTS_CEILING {
128        return Err(ConfigErrorRepr::OverCeiling {
129            field: "max_redirects",
130            value,
131            ceiling: MAX_REDIRECTS_CEILING,
132        });
133    }
134    Ok(MaxRedirects(value))
135}
136
137/// Validates a timeout as strictly positive and within its ceiling.
138pub(super) fn validate_timeout(
139    field: &'static str,
140    value: Duration,
141    ceiling: Duration,
142) -> Result<Duration, ConfigErrorRepr> {
143    if value.is_zero() {
144        return Err(ConfigErrorRepr::ZeroTimeout { field });
145    }
146    if value > ceiling {
147        return Err(ConfigErrorRepr::TimeoutOverCeiling {
148            field,
149            value,
150            ceiling,
151        });
152    }
153    Ok(value)
154}
155
156/// Parses and validates the denied-CIDR strings into networks.
157pub(super) fn validate_deny_cidrs(cidrs: Vec<String>) -> Result<Vec<IpNet>, ConfigErrorRepr> {
158    let mut nets = Vec::with_capacity(cidrs.len());
159    for cidr in cidrs {
160        let net = cidr
161            .parse::<IpNet>()
162            .map_err(|source| ConfigErrorRepr::Cidr {
163                cidr: cidr.clone(),
164                source,
165            })?;
166        if !nets.contains(&net) {
167            nets.push(net);
168        }
169    }
170    Ok(nets)
171}
172
173/// Canonicalizes and validates one exact-exception host.
174///
175/// Accepts an IP literal (for a literal-host exception) or a syntactically valid
176/// DNS domain. Every other form - empty, whitespace-, slash-, colon-, at-, or
177/// query-bearing - is rejected by the URL host parser, which enforces the URL
178/// forbidden-host-code-point set. Returns the canonical host to store.
179fn validate_host(raw: &str) -> Result<String, ConfigErrorRepr> {
180    let host = canonical_host(raw);
181    if host.is_empty() {
182        return Err(ConfigErrorRepr::Host {
183            host: raw.to_string(),
184        });
185    }
186    // An IP literal is a legitimate exact-exception host (a literal-host URL).
187    if host.parse::<IpAddr>().is_ok() {
188        return Ok(host);
189    }
190    // Otherwise require a valid DNS domain. `url::Host::parse` rejects every
191    // forbidden host code point (`:` outside brackets, `@`, `?`, `#`, `/`,
192    // whitespace, ...), so `bad:host`, `bad@host`, and `bad?host` are refused,
193    // while a non-domain address form is not a valid exact host here.
194    match url::Host::parse(&host) {
195        Ok(url::Host::Domain(domain)) => Ok(domain),
196        _ => Err(ConfigErrorRepr::Host {
197            host: raw.to_string(),
198        }),
199    }
200}
201
202/// Canonicalizes and validates the exact host-plus-address exceptions.
203pub(super) fn validate_allow_hosts(
204    hosts: Vec<(String, IpAddr)>,
205) -> Result<Vec<HostAddressException>, ConfigErrorRepr> {
206    let mut out: Vec<HostAddressException> = Vec::with_capacity(hosts.len());
207    for (raw, addr) in hosts {
208        let host = validate_host(&raw)?;
209        let entry = HostAddressException { host, addr };
210        if !out.contains(&entry) {
211            out.push(entry);
212        }
213    }
214    Ok(out)
215}