Skip to main content

tirith_core/
policy_client.rs

1use std::fmt;
2use std::time::Duration;
3
4/// Errors that can occur when fetching remote policy.
5#[derive(Debug)]
6pub enum PolicyFetchError {
7    /// Network-level error (DNS, connection refused, timeout, etc.).
8    NetworkError(String),
9    /// Authentication failure (401/403). Always treated as fatal.
10    AuthError(u16),
11    /// Server returned an error status code.
12    ServerError(String),
13    /// Response body could not be read or is not valid YAML.
14    InvalidResponse(String),
15}
16
17impl fmt::Display for PolicyFetchError {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        match self {
20            PolicyFetchError::NetworkError(msg) => write!(f, "network error: {msg}"),
21            PolicyFetchError::AuthError(code) => write!(f, "authentication failed (HTTP {code})"),
22            PolicyFetchError::ServerError(msg) => write!(f, "server error: {msg}"),
23            PolicyFetchError::InvalidResponse(msg) => write!(f, "invalid response: {msg}"),
24        }
25    }
26}
27
28/// Fetch remote policy YAML from the policy server.
29///
30/// Uses 5s connect timeout and 10s total timeout. The server endpoint
31/// is `{url}/api/policy/fetch` and requires Bearer token authentication.
32#[cfg(unix)]
33pub fn fetch_remote_policy(url: &str, api_key: &str) -> Result<String, PolicyFetchError> {
34    // SSRF protection: validate the URL before connecting
35    if let Err(reason) = crate::url_validate::validate_server_url(url) {
36        return Err(PolicyFetchError::NetworkError(reason));
37    }
38
39    let client = reqwest::blocking::Client::builder()
40        .connect_timeout(Duration::from_secs(5))
41        .timeout(Duration::from_secs(10))
42        .build()
43        .map_err(|e| PolicyFetchError::NetworkError(e.to_string()))?;
44
45    let endpoint = format!("{}/api/policy/fetch", url.trim_end_matches('/'));
46    let resp = client
47        .get(&endpoint)
48        .header("Authorization", format!("Bearer {api_key}"))
49        .send()
50        .map_err(|e| PolicyFetchError::NetworkError(e.to_string()))?;
51
52    match resp.status().as_u16() {
53        200 => resp
54            .text()
55            .map_err(|e| PolicyFetchError::InvalidResponse(e.to_string())),
56        401 | 403 => Err(PolicyFetchError::AuthError(resp.status().as_u16())),
57        404 => Err(PolicyFetchError::ServerError(
58            "no active policy found".into(),
59        )),
60        s => Err(PolicyFetchError::ServerError(format!(
61            "server returned HTTP {s}"
62        ))),
63    }
64}
65
66/// Stub for non-unix platforms where reqwest is not available.
67#[cfg(not(unix))]
68pub fn fetch_remote_policy(_url: &str, _api_key: &str) -> Result<String, PolicyFetchError> {
69    Err(PolicyFetchError::NetworkError(
70        "remote policy fetch is not supported on this platform".into(),
71    ))
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn test_policy_fetch_error_display() {
80        let e = PolicyFetchError::NetworkError("timeout".into());
81        assert_eq!(format!("{e}"), "network error: timeout");
82
83        let e = PolicyFetchError::AuthError(401);
84        assert_eq!(format!("{e}"), "authentication failed (HTTP 401)");
85
86        let e = PolicyFetchError::ServerError("internal error".into());
87        assert_eq!(format!("{e}"), "server error: internal error");
88
89        let e = PolicyFetchError::InvalidResponse("bad body".into());
90        assert_eq!(format!("{e}"), "invalid response: bad body");
91    }
92
93    #[test]
94    fn test_fetch_invalid_url_returns_network_error() {
95        // Non-routable address should fail quickly
96        let result = fetch_remote_policy("http://192.0.2.1:1", "test-key");
97        assert!(result.is_err());
98        match result.unwrap_err() {
99            PolicyFetchError::NetworkError(_) => {} // expected
100            other => panic!("expected NetworkError, got: {other}"),
101        }
102    }
103}