agentzero_core/common/
util.rs1use super::url_policy::{enforce_url_policy, UrlAccessPolicy, UrlPolicyResult};
2use anyhow::anyhow;
3use std::collections::HashMap;
4use url::Url;
5
6pub 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
23pub 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
37pub fn build_query_string(params: &HashMap<&str, &str>) -> String {
47 encode_pairs(params.iter().map(|(&k, &v)| (k, v)))
48}
49
50pub 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(¶ms);
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(¶ms);
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(¶ms);
141 assert_eq!(
142 qs,
143 "redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"
144 );
145 }
146}