Skip to main content

agentzero_core/common/
util.rs

1use super::url_policy::{enforce_url_policy, UrlAccessPolicy, UrlPolicyResult};
2use anyhow::anyhow;
3use std::collections::HashMap;
4use url::Url;
5
6/// Parse and validate an HTTP/HTTPS URL (scheme + host check only).
7pub fn parse_http_url(input: &str) -> anyhow::Result<Url> {
8    let trimmed = input.trim();
9    if trimmed.is_empty() {
10        return Err(anyhow!("url input is required"));
11    }
12
13    let parsed = Url::parse(trimmed).map_err(|err| anyhow!("invalid url: {err}"))?;
14    if !matches!(parsed.scheme(), "http" | "https") {
15        return Err(anyhow!("unsupported url scheme `{}`", parsed.scheme()));
16    }
17    if parsed.host_str().is_none() {
18        return Err(anyhow!("url host is required"));
19    }
20    Ok(parsed)
21}
22
23/// Parse an HTTP URL and enforce the URL access policy.
24///
25/// Returns the parsed URL if allowed, or an error describing why access is denied.
26pub fn parse_http_url_with_policy(input: &str, policy: &UrlAccessPolicy) -> anyhow::Result<Url> {
27    let parsed = parse_http_url(input)?;
28    match enforce_url_policy(&parsed, policy) {
29        UrlPolicyResult::Allowed => Ok(parsed),
30        UrlPolicyResult::RequiresApproval { domain } => {
31            Err(anyhow!("domain `{domain}` requires first-visit approval"))
32        }
33        UrlPolicyResult::Blocked { reason } => Err(anyhow!("URL access denied: {reason}")),
34    }
35}
36
37/// Build a URL query string using standard percent-encoding (`%20` for spaces).
38///
39/// OAuth authorize endpoints and similar browser-facing URLs require `%20` for
40/// spaces instead of `+` (which is only valid in `application/x-www-form-urlencoded`
41/// POST bodies). This function matches the encoding used by the official OpenAI
42/// Codex CLI (`urlencoding::encode`).
43///
44/// Keys are emitted in the order returned by the HashMap iterator. Use
45/// [`build_query_string_ordered`] when deterministic ordering is required.
46pub fn build_query_string(params: &HashMap<&str, &str>) -> String {
47    encode_pairs(params.iter().map(|(&k, &v)| (k, v)))
48}
49
50/// Like [`build_query_string`] but emits keys in the provided order.
51///
52/// Use this when the parameter order matters (e.g. for readable URLs or when a
53/// server is sensitive to ordering).
54pub fn build_query_string_ordered(params: &[(&str, &str)]) -> String {
55    encode_pairs(params.iter().map(|&(k, v)| (k, v)))
56}
57
58fn encode_pairs<'a>(pairs: impl Iterator<Item = (&'a str, &'a str)>) -> String {
59    pairs
60        .map(|(k, v)| {
61            let ek: String = url::form_urlencoded::byte_serialize(k.as_bytes()).collect();
62            let ev: String = url::form_urlencoded::byte_serialize(v.as_bytes()).collect();
63            format!("{ek}={ev}")
64        })
65        .collect::<Vec<_>>()
66        .join("&")
67        .replace('+', "%20")
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn parse_http_url_accepts_https_success_path() {
76        let parsed = parse_http_url("https://example.com/path").expect("https should parse");
77        assert_eq!(parsed.scheme(), "https");
78        assert_eq!(parsed.host_str(), Some("example.com"));
79    }
80
81    #[test]
82    fn parse_http_url_rejects_non_http_scheme_negative_path() {
83        let err = parse_http_url("file:///tmp/test").expect_err("file scheme should fail");
84        assert!(err.to_string().contains("unsupported url scheme"));
85    }
86
87    #[test]
88    fn parse_with_policy_blocks_private_ip() {
89        let policy = UrlAccessPolicy::default();
90        let err = parse_http_url_with_policy("http://192.168.1.1/api", &policy)
91            .expect_err("private IP should be blocked");
92        assert!(err.to_string().contains("URL access denied"));
93    }
94
95    #[test]
96    fn parse_with_policy_allows_public_url() {
97        let policy = UrlAccessPolicy::default();
98        let parsed = parse_http_url_with_policy("https://example.com/path", &policy)
99            .expect("public URL should be allowed");
100        assert_eq!(parsed.host_str(), Some("example.com"));
101    }
102
103    #[test]
104    fn parse_with_policy_blocks_blocklisted_domain() {
105        let policy = UrlAccessPolicy {
106            domain_blocklist: vec!["blocked.com".to_string()],
107            ..Default::default()
108        };
109        let err = parse_http_url_with_policy("https://blocked.com/path", &policy)
110            .expect_err("blocklisted domain should be blocked");
111        assert!(err.to_string().contains("URL access denied"));
112    }
113
114    #[test]
115    fn build_query_string_encodes_spaces_as_percent20() {
116        let mut params = HashMap::new();
117        params.insert("scope", "openid profile email");
118        let qs = build_query_string(&params);
119        assert!(qs.contains("scope=openid%20profile%20email"));
120        assert!(!qs.contains('+'));
121    }
122
123    #[test]
124    fn build_query_string_ordered_preserves_insertion_order() {
125        let params = vec![
126            ("response_type", "code"),
127            ("client_id", "my_app"),
128            ("scope", "openid email"),
129        ];
130        let qs = build_query_string_ordered(&params);
131        assert_eq!(
132            qs,
133            "response_type=code&client_id=my_app&scope=openid%20email"
134        );
135    }
136
137    #[test]
138    fn build_query_string_ordered_encodes_special_chars() {
139        let params = vec![("redirect_uri", "http://localhost:1455/auth/callback")];
140        let qs = build_query_string_ordered(&params);
141        assert_eq!(
142            qs,
143            "redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"
144        );
145    }
146}