use std::time::{Duration, SystemTime};
use crate::error::{Error, Result};
use crate::policy::{Act, Effect, Policy, Verdict};
use crate::state::{PolicyEvent, Store};
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
pub(crate) fn http_client() -> reqwest::Client {
http_client_with_timeout(REQUEST_TIMEOUT)
}
pub(crate) fn http_client_with_timeout(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
pub(crate) fn retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
parse_retry_after(headers.get("retry-after")?.to_str().ok()?)
}
fn parse_retry_after(value: &str) -> Option<Duration> {
let value = value.trim();
if let Ok(secs) = value.parse::<u64>() {
return Some(Duration::from_secs(secs));
}
let at = parse_http_date(value)?;
Some(
at.duration_since(SystemTime::now())
.unwrap_or(Duration::ZERO),
)
}
fn parse_http_date(value: &str) -> Option<SystemTime> {
let mut parts = value.split_whitespace();
let (_weekday, day, month, year, time, zone) = (
parts.next()?,
parts.next()?,
parts.next()?,
parts.next()?,
parts.next()?,
parts.next()?,
);
if !zone.eq_ignore_ascii_case("GMT") || parts.next().is_some() {
return None;
}
let day: i64 = day.parse().ok()?;
let year: i64 = year.parse().ok()?;
let month = 1 + [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
.iter()
.position(|m| *m == month)? as i64;
let mut hms = time.split(':');
let (h, m, s): (i64, i64, i64) = (
hms.next()?.parse().ok()?,
hms.next()?.parse().ok()?,
hms.next()?.parse().ok()?,
);
if hms.next().is_some() || !(1..=31).contains(&day) || h > 23 || m > 59 || s > 60 {
return None;
}
let y = if month <= 2 { year - 1 } else { year };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (month + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
let secs = days * 86_400 + h * 3_600 + m * 60 + s;
(secs >= 0).then(|| SystemTime::UNIX_EPOCH + Duration::from_secs(secs as u64))
}
pub(crate) fn target(url: &str) -> Option<String> {
let (scheme, rest) = url.split_once("://")?;
let authority = rest
.split(['/', '?', '#'])
.next()
.filter(|a| !a.is_empty())?;
let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let default_port = match scheme.to_ascii_lowercase().as_str() {
"https" | "wss" => "443",
"http" | "ws" => "80",
_ => return None,
};
if let Some(close) = hostport.strip_prefix('[').and_then(|_| hostport.find(']')) {
let host = &hostport[..=close];
return match hostport[close + 1..].strip_prefix(':') {
Some(port) if !port.is_empty() => Some(format!("{host}:{port}")),
_ => Some(format!("{host}:{default_port}")),
};
}
match hostport.split_once(':') {
Some((host, port)) if !host.is_empty() && !port.is_empty() => {
Some(format!("{host}:{port}"))
}
Some(_) => None,
None => Some(format!("{hostport}:{default_port}")),
}
}
pub(crate) struct NetGuard<'a> {
policy: &'a Policy,
trace: Option<(&'a Store, i64, u32)>,
watch: Option<(&'a crate::run::Watch<'a>, u32)>,
}
impl<'a> NetGuard<'a> {
pub(crate) fn new(policy: &'a Policy) -> Self {
Self {
policy,
trace: None,
watch: None,
}
}
pub(crate) fn tracing(mut self, store: &'a Store, run_id: i64, step: u32) -> Self {
self.trace = Some((store, run_id, step));
self
}
pub(crate) fn watching(mut self, watch: &'a crate::run::Watch<'a>, depth: u32) -> Self {
self.watch = Some((watch, depth));
self
}
pub(crate) fn check(&self, url: &str) -> Result<Verdict> {
let Some(target) = target(url) else {
return Err(Error::Refused {
act: "net".into(),
target: url.to_string(),
rule: None,
layer: None,
});
};
self.check_target(&target)
}
pub(crate) fn check_target(&self, target: &str) -> Result<Verdict> {
let verdict = self.policy.check(Act::Net, target);
if let Some((store, run_id, step)) = self.trace {
let mut ev = match verdict.effect {
Effect::Allow => PolicyEvent::decision(step, "net", target, "allow", "policy"),
Effect::Ask => PolicyEvent::decision(step, "net", target, "ask", "policy"),
Effect::Deny => PolicyEvent::refusal(step, "net", target),
};
ev.rule = verdict.rule.clone();
ev.layer = verdict.layer.clone();
if verdict.effect == Effect::Deny {
if let Some((watch, depth)) = self.watch {
crate::run::refused(watch, run_id, depth, &ev);
}
}
let _ = store.record_event(run_id, &ev);
}
if verdict.effect == Effect::Deny {
return Err(Error::Refused {
act: "net".into(),
target: target.to_string(),
rule: verdict.rule,
layer: verdict.layer,
});
}
Ok(verdict)
}
}
pub(crate) const PROVIDER_LAYER: &str = "provider";
pub(crate) fn provider_layer(target: &str) -> Policy {
Policy::permissive().layer(PROVIDER_LAYER).allow_net(target)
}
#[cfg(test)]
pub(crate) fn http_date(unix_secs: u64) -> String {
let days = (unix_secs / 86_400) as i64;
let tod = unix_secs % 86_400;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
let month = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
][(m - 1) as usize];
format!(
"Mon, {d:02} {month} {year} {:02}:{:02}:{:02} GMT",
tod / 3600,
(tod % 3600) / 60,
tod % 60
)
}
#[cfg(test)]
pub(crate) fn unix_now() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retry_after_reads_delta_seconds() {
assert_eq!(parse_retry_after("7"), Some(Duration::from_secs(7)));
assert_eq!(parse_retry_after(" 0 "), Some(Duration::ZERO));
assert_eq!(parse_retry_after("120"), Some(Duration::from_secs(120)));
}
#[test]
fn retry_after_reads_an_http_date_as_the_wait_until_then() {
assert_eq!(
parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT"),
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(784_111_777))
);
assert_eq!(
parse_http_date("Thu, 01 Jan 1970 00:00:00 GMT"),
Some(SystemTime::UNIX_EPOCH)
);
assert_eq!(
parse_http_date("Tue, 29 Feb 2000 12:00:00 GMT"),
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(951_825_600))
);
let waited = parse_retry_after(&http_date(unix_now() + 30)).unwrap();
assert!(
waited <= Duration::from_secs(31) && waited >= Duration::from_secs(28),
"{waited:?}"
);
}
#[test]
fn a_retry_after_in_the_past_is_a_wait_of_zero() {
let past = http_date(unix_now() - 600);
assert_eq!(parse_retry_after(&past), Some(Duration::ZERO));
}
#[test]
fn an_unparseable_retry_after_is_treated_as_absent() {
for value in [
"",
"soon",
"-5",
"7.5",
"Sun, 06 Nov 1994 08:49:37 PST", "Sun, 06 Nov 1994 08:49 GMT", "Sun, 06 Nov 1994 08:49:37", "Sun, 06 Nov 1994 08:49:37 GMT extra",
"Sun, 32 Nov 1994 08:49:37 GMT", "Sun, 06 Nov 1994 24:49:37 GMT", "Sun, 06 Foo 1994 08:49:37 GMT", "Thu, 01 Jan 1969 00:00:00 GMT", ] {
assert_eq!(parse_retry_after(value), None, "{value:?}");
}
}
#[test]
fn a_retry_after_header_is_read_off_the_response_headers() {
let mut headers = reqwest::header::HeaderMap::new();
assert_eq!(retry_after(&headers), None);
headers.insert("retry-after", "42".parse().unwrap());
assert_eq!(retry_after(&headers), Some(Duration::from_secs(42)));
}
#[test]
fn a_url_becomes_host_and_port() {
for (url, want) in [
(
"https://api.openai.com/v1/chat/completions",
"api.openai.com:443",
),
("http://127.0.0.1:8931/mcp", "127.0.0.1:8931"),
(
"https://openrouter.ai/api/v1/chat/completions",
"openrouter.ai:443",
),
("http://example.com", "example.com:80"),
("https://example.com:8443/x?y=1#z", "example.com:8443"),
("https://user:pw@example.com/x", "example.com:443"),
("https://[::1]/x", "[::1]:443"),
("https://[::1]:8080/x", "[::1]:8080"),
] {
assert_eq!(target(url).as_deref(), Some(want), "{url}");
}
}
#[test]
fn an_uncheckable_url_is_refused_not_waved_through() {
for url in [
"",
"not a url",
"file:///etc/passwd",
"https://",
"https://host:/x",
] {
assert_eq!(target(url), None, "{url}");
let p = Policy::permissive();
assert!(matches!(
NetGuard::new(&p).check(url),
Err(Error::Refused { .. })
));
}
}
#[test]
fn deny_is_an_error_and_allow_is_a_verdict() {
let p = Policy::default().layer("l").allow_net("api.example.com");
let guard = NetGuard::new(&p);
assert_eq!(
guard.check("https://api.example.com/v1").unwrap().effect,
Effect::Allow
);
assert!(matches!(
guard.check("https://evil.example.com/v1"),
Err(Error::Refused { act, .. }) if act == "net"
));
}
#[test]
fn the_provider_layer_is_named_and_a_caller_deny_still_wins() {
let base = Policy::default(); let with_provider = base.merge(provider_layer("api.example.com:443"));
let v = with_provider.explain(Act::Net, "api.example.com:443");
assert_eq!(v.effect, Effect::Allow);
assert_eq!(v.layer.as_deref(), Some(PROVIDER_LAYER));
let locked = with_provider.layer("caller").deny_net("api.example.com");
assert_eq!(
locked.check(Act::Net, "api.example.com:443").effect,
Effect::Deny
);
}
}