Skip to main content

acp_runtime/
http_security.rs

1// Copyright 2026 ACP Project
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5use 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}