acp_runtime/
http_security.rs1use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use reqwest::Certificate;
10use reqwest::Identity;
11use reqwest::blocking::Client;
12use url::Url;
13
14use crate::errors::{AcpError, AcpResult};
15
16#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
17pub struct HttpSecurityPolicy {
18 #[serde(default)]
19 pub allow_insecure_http: bool,
20 #[serde(default)]
21 pub allow_insecure_tls: bool,
22 #[serde(default)]
23 pub mtls_enabled: bool,
24 pub ca_file: Option<String>,
25 pub cert_file: Option<String>,
26 pub key_file: Option<String>,
27}
28
29pub fn validate_http_url(
30 url: &str,
31 allow_insecure_http: bool,
32 mtls_enabled: bool,
33 context: &str,
34) -> AcpResult<Url> {
35 let parsed = Url::parse(url)?;
36 match parsed.scheme() {
37 "http" | "https" => {}
38 _ => {
39 return Err(AcpError::Validation(format!(
40 "{context} requires an http(s) URL, got: {url}"
41 )));
42 }
43 }
44 if parsed.host_str().unwrap_or_default().trim().is_empty() {
45 return Err(AcpError::Validation(format!(
46 "{context} URL is missing host: {url}"
47 )));
48 }
49 if parsed.scheme() == "http" && mtls_enabled {
50 return Err(AcpError::Validation(format!(
51 "{context} cannot use HTTP ({url}) when mtls_enabled=true. Use https:// endpoints."
52 )));
53 }
54 if parsed.scheme() == "http" && !allow_insecure_http {
55 return Err(AcpError::Validation(format!(
56 "{context} uses insecure HTTP ({url}). Set allow_insecure_http=true only for local/dev/demo workflows."
57 )));
58 }
59 Ok(parsed)
60}
61
62pub fn validate_http_client_policy(policy: &HttpSecurityPolicy, context: &str) -> AcpResult<()> {
63 let _ca = normalize_optional_file(policy.ca_file.as_deref(), context, "ca_file")?;
64 let cert = normalize_optional_file(policy.cert_file.as_deref(), context, "cert_file")?;
65 let key = normalize_optional_file(policy.key_file.as_deref(), context, "key_file")?;
66 if policy.mtls_enabled {
67 if cert.is_none() {
68 return Err(AcpError::Validation(format!(
69 "{context} requires cert_file when mtls_enabled=true"
70 )));
71 }
72 if key.is_none() {
73 return Err(AcpError::Validation(format!(
74 "{context} requires key_file when mtls_enabled=true"
75 )));
76 }
77 } else if cert.is_some() ^ key.is_some() {
78 return Err(AcpError::Validation(format!(
79 "{context} requires both cert_file and key_file when either is configured"
80 )));
81 }
82 Ok(())
83}
84
85pub fn build_http_client(timeout_seconds: u64, policy: &HttpSecurityPolicy) -> AcpResult<Client> {
86 validate_http_client_policy(policy, "HTTP client configuration")?;
87 let mut builder = Client::builder()
88 .timeout(Duration::from_secs(timeout_seconds.max(1)))
89 .connect_timeout(Duration::from_secs(timeout_seconds.max(1)));
90
91 if policy.allow_insecure_tls {
92 builder = builder
93 .danger_accept_invalid_certs(true)
94 .danger_accept_invalid_hostnames(true);
95 }
96
97 if let Some(ca_path) = normalize_optional_file(
98 policy.ca_file.as_deref(),
99 "HTTP client configuration",
100 "ca_file",
101 )? {
102 let bytes = fs::read(ca_path)?;
103 let cert = Certificate::from_pem(&bytes)
104 .map_err(|e| AcpError::Validation(format!("invalid ca_file PEM: {e}")))?;
105 builder = builder.add_root_certificate(cert);
106 }
107
108 if policy.mtls_enabled {
109 let cert_path = normalize_optional_file(
110 policy.cert_file.as_deref(),
111 "HTTP client configuration",
112 "cert_file",
113 )?
114 .ok_or_else(|| {
115 AcpError::Validation("cert_file is required when mtls_enabled=true".to_string())
116 })?;
117 let key_path = normalize_optional_file(
118 policy.key_file.as_deref(),
119 "HTTP client configuration",
120 "key_file",
121 )?
122 .ok_or_else(|| {
123 AcpError::Validation("key_file is required when mtls_enabled=true".to_string())
124 })?;
125 let cert_pem = fs::read(cert_path)?;
126 let key_pem = fs::read(key_path)?;
127 let mut combined = Vec::with_capacity(cert_pem.len() + key_pem.len() + 2);
128 combined.extend_from_slice(&cert_pem);
129 if !combined.ends_with(b"\n") {
130 combined.push(b'\n');
131 }
132 combined.extend_from_slice(&key_pem);
133 let identity = Identity::from_pem(&combined)
134 .map_err(|e| AcpError::Validation(format!("invalid mTLS cert/key PEM: {e}")))?;
135 builder = builder.identity(identity);
136 }
137
138 builder
139 .build()
140 .map_err(|e| AcpError::Transport(format!("unable to build HTTP client: {e}")))
141}
142
143pub fn normalize_optional_file(
144 value: Option<&str>,
145 context: &str,
146 label: &str,
147) -> AcpResult<Option<PathBuf>> {
148 let Some(raw) = value.map(str::trim).filter(|v| !v.is_empty()) else {
149 return Ok(None);
150 };
151 let path = Path::new(raw);
152 if !path.is_file() {
153 return Err(AcpError::Validation(format!(
154 "{context} {label} does not exist or is not a file: {raw}"
155 )));
156 }
157 Ok(Some(path.to_path_buf()))
158}