hyper_custom_cert/lib.rs
1//! hyper-custom-cert
2//!
3//! A reusable HTTP client library that provides:
4//! - A small, ergonomic wrapper surface for building HTTP clients
5//! - A dev-only option to accept self-signed/invalid certificates (feature-gated)
6//! - A production-grade path to trust a custom Root CA by providing PEM bytes
7//! - Clear security boundaries and feature flags
8//!
9//! Note: Networking internals are intentionally abstracted for now; this crate
10//! focuses on a robust and secure configuration API surfaced via a builder.
11//!
12//! WebAssembly support and limitations
13//! -----------------------------------
14//! For wasm32 targets, this crate currently exposes API stubs that return
15//! `ClientError::WasmNotImplemented` when attempting to perform operations that
16//! would require configuring a TLS client with a custom Root CA. This is by design:
17//!
18//! Browsers do not allow web applications to programmatically install or trust
19//! custom Certificate Authorities. Trust decisions are enforced by the browser and
20//! the underlying OS. As a result, while native builds can securely add a custom
21//! Root CA (e.g., via `with_root_ca_pem` behind the `rustls` feature), the same is
22//! not possible in the browser environment. Any runtime method that would require
23//! such behavior will return `WasmNotImplemented` on wasm targets.
24//!
25//! If you need to target WebAssembly, build with `--no-default-features` to avoid
26//! pulling in native TLS dependencies, and expect stubbed behavior until a future
27//! browser capability or design change enables safe support.
28
29use std::collections::HashMap;
30use std::error::Error as StdError;
31use std::fmt;
32#[cfg(feature = "rustls")]
33use std::fs;
34#[cfg(feature = "rustls")]
35use std::path::Path;
36use std::time::Duration;
37
38/// Error type for this crate's runtime operations.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ClientError {
41 /// Returned on wasm32 targets where runtime operations requiring custom CA
42 /// trust are not available due to browser security constraints.
43 WasmNotImplemented,
44}
45
46impl fmt::Display for ClientError {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 ClientError::WasmNotImplemented => write!(
50 f,
51 "Not implemented on WebAssembly (browser restricts programmatic CA trust)"
52 ),
53 }
54 }
55}
56
57impl StdError for ClientError {}
58
59/// Reusable HTTP client configured via [`HttpClientBuilder`].
60///
61/// # Examples
62///
63/// Build a client with a custom timeout and default headers:
64///
65/// ```
66/// use hyper_custom_cert::HttpClient;
67/// use std::time::Duration;
68/// use std::collections::HashMap;
69///
70/// let mut headers = HashMap::new();
71/// headers.insert("x-app".into(), "demo".into());
72///
73/// let client = HttpClient::builder()
74/// .with_timeout(Duration::from_secs(10))
75/// .with_default_headers(headers)
76/// .build();
77///
78/// // Placeholder call; does not perform I/O in this crate.
79/// let _ = client.request("https://example.com");
80/// ```
81pub struct HttpClient {
82 timeout: Duration,
83 default_headers: HashMap<String, String>,
84 /// When enabled (dev-only feature), allows accepting invalid/self-signed certs.
85 #[cfg(feature = "insecure-dangerous")]
86 accept_invalid_certs: bool,
87 /// Optional PEM-encoded custom Root CA to trust in addition to system roots.
88 root_ca_pem: Option<Vec<u8>>,
89 /// Optional certificate pins for additional security beyond CA validation.
90 #[cfg(feature = "rustls")]
91 pinned_cert_sha256: Option<Vec<[u8; 32]>>,
92}
93
94impl HttpClient {
95 /// Construct a new client using secure defaults by delegating to the builder.
96 pub fn new() -> Self {
97 HttpClientBuilder::new().build()
98 }
99
100 /// Start building a client with explicit configuration.
101 pub fn builder() -> HttpClientBuilder {
102 HttpClientBuilder::new()
103 }
104
105 /// Convenience constructor that enables acceptance of self-signed/invalid
106 /// certificates. This is gated behind the `insecure-dangerous` feature and intended
107 /// strictly for development and testing. NEVER enable in production.
108 #[cfg(feature = "insecure-dangerous")]
109 pub fn with_self_signed_certs() -> Self {
110 HttpClient::builder()
111 .insecure_accept_invalid_certs(true)
112 .build()
113 }
114}
115
116// Native (non-wasm) runtime placeholder implementation
117#[cfg(not(target_arch = "wasm32"))]
118impl HttpClient {
119 /// Minimal runtime method to demonstrate how requests would be issued.
120 /// On native targets, this currently returns Ok(()) as a placeholder
121 /// without performing network I/O.
122 pub fn request(&self, _url: &str) -> Result<(), ClientError> {
123 // Touch configuration fields to avoid dead_code warnings until
124 // network I/O is implemented.
125 let _ = (&self.timeout, &self.default_headers, &self.root_ca_pem);
126 #[cfg(feature = "insecure-dangerous")]
127 let _ = &self.accept_invalid_certs;
128 #[cfg(feature = "rustls")]
129 let _ = &self.pinned_cert_sha256;
130 Ok(())
131 }
132}
133
134// WebAssembly stubbed runtime implementation
135#[cfg(target_arch = "wasm32")]
136impl HttpClient {
137 /// On wasm32 targets, runtime methods are stubbed and return
138 /// `ClientError::WasmNotImplemented` because browsers do not allow
139 /// programmatic installation/trust of custom CAs.
140 pub fn request(&self, _url: &str) -> Result<(), ClientError> {
141 Err(ClientError::WasmNotImplemented)
142 }
143}
144
145/// Builder for configuring and creating an [`HttpClient`].
146pub struct HttpClientBuilder {
147 timeout: Duration,
148 default_headers: HashMap<String, String>,
149 #[cfg(feature = "insecure-dangerous")]
150 accept_invalid_certs: bool,
151 root_ca_pem: Option<Vec<u8>>,
152 #[cfg(feature = "rustls")]
153 pinned_cert_sha256: Option<Vec<[u8; 32]>>,
154}
155
156impl HttpClientBuilder {
157 /// Start a new builder with default settings.
158 pub fn new() -> Self {
159 Self {
160 timeout: Duration::from_secs(30),
161 default_headers: HashMap::new(),
162 #[cfg(feature = "insecure-dangerous")]
163 accept_invalid_certs: false,
164 root_ca_pem: None,
165 #[cfg(feature = "rustls")]
166 pinned_cert_sha256: None,
167 }
168 }
169
170 /// Set a request timeout to apply to client operations.
171 pub fn with_timeout(mut self, timeout: Duration) -> Self {
172 self.timeout = timeout;
173 self
174 }
175
176 /// Set default headers that will be added to every request initiated by this client.
177 pub fn with_default_headers(mut self, headers: HashMap<String, String>) -> Self {
178 self.default_headers = headers;
179 self
180 }
181
182 /// Dev-only: accept self-signed/invalid TLS certificates. Requires the
183 /// `insecure-dangerous` feature to be enabled. NEVER enable this in production.
184 ///
185 /// # Examples
186 ///
187 /// Enable insecure mode during local development (dangerous):
188 ///
189 /// ```ignore
190 /// use hyper_custom_cert::HttpClient;
191 ///
192 /// // Requires: --features insecure-dangerous
193 /// let client = HttpClient::builder()
194 /// .insecure_accept_invalid_certs(true)
195 /// .build();
196 /// ```
197 #[cfg(feature = "insecure-dangerous")]
198 pub fn insecure_accept_invalid_certs(mut self, accept: bool) -> Self {
199 self.accept_invalid_certs = accept;
200 self
201 }
202
203 /// Provide a PEM-encoded Root CA certificate to be trusted by the client.
204 /// This is the production-ready way to trust a custom CA.
205 ///
206 /// # Examples
207 ///
208 /// ```ignore
209 /// use hyper_custom_cert::HttpClient;
210 ///
211 /// // Requires: --no-default-features --features rustls
212 /// let client = HttpClient::builder()
213 /// .with_root_ca_pem(include_bytes!("../examples-data/root-ca.pem"))
214 /// .build();
215 /// ```
216 #[cfg(feature = "rustls")]
217 pub fn with_root_ca_pem(mut self, pem_bytes: &[u8]) -> Self {
218 self.root_ca_pem = Some(pem_bytes.to_vec());
219 self
220 }
221
222 /// Provide a PEM-encoded Root CA certificate file to be trusted by the client.
223 /// This is the production-ready way to trust a custom CA from a file path.
224 ///
225 /// The file will be read during builder configuration and its contents stored
226 /// in the client. This method will panic if the file cannot be read, similar
227 /// to how `include_bytes!` macro behaves.
228 ///
229 /// # Security Considerations
230 ///
231 /// Only use certificate files from trusted sources. Ensure proper file permissions
232 /// are set to prevent unauthorized modification of the certificate file.
233 ///
234 /// # Panics
235 ///
236 /// This method will panic if:
237 /// - The file does not exist
238 /// - The file cannot be read due to permissions or I/O errors
239 /// - The path is invalid
240 ///
241 /// # Examples
242 ///
243 /// ```ignore
244 /// use hyper_custom_cert::HttpClient;
245 ///
246 /// // Requires: --no-default-features --features rustls
247 /// let client = HttpClient::builder()
248 /// .with_root_ca_file("path/to/root-ca.pem")
249 /// .build();
250 /// ```
251 ///
252 /// Using a `std::path::Path`:
253 ///
254 /// ```ignore
255 /// use hyper_custom_cert::HttpClient;
256 /// use std::path::Path;
257 ///
258 /// // Requires: --no-default-features --features rustls
259 /// let ca_path = Path::new("certs/custom-ca.pem");
260 /// let client = HttpClient::builder()
261 /// .with_root_ca_file(ca_path)
262 /// .build();
263 /// ```
264 #[cfg(feature = "rustls")]
265 pub fn with_root_ca_file<P: AsRef<Path>>(mut self, path: P) -> Self {
266 let pem_bytes = fs::read(path.as_ref()).unwrap_or_else(|e| {
267 panic!(
268 "Failed to read CA certificate file '{}': {}",
269 path.as_ref().display(),
270 e
271 )
272 });
273 self.root_ca_pem = Some(pem_bytes);
274 self
275 }
276
277 /// Configure certificate pinning using SHA256 fingerprints for additional security.
278 ///
279 /// Certificate pinning provides an additional layer of security beyond CA validation
280 /// by verifying that the server's certificate matches one of the provided fingerprints.
281 /// This helps protect against compromised CAs and man-in-the-middle attacks.
282 ///
283 /// # Security Considerations
284 ///
285 /// - Certificate pinning should be used in conjunction with, not as a replacement for,
286 /// proper CA validation.
287 /// - Pinned certificates must be updated when the server's certificate changes.
288 /// - Consider having backup pins for certificate rotation scenarios.
289 /// - This method provides additional security but requires careful maintenance.
290 ///
291 /// # Parameters
292 ///
293 /// * `pins` - A vector of 32-byte SHA256 fingerprints of certificates to pin.
294 /// Each fingerprint should be the SHA256 hash of the certificate's DER encoding.
295 ///
296 /// # Examples
297 ///
298 /// ```ignore
299 /// use hyper_custom_cert::HttpClient;
300 ///
301 /// // Example SHA256 fingerprints (these are just examples)
302 /// let pin1: [u8; 32] = [
303 /// 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0,
304 /// 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
305 /// 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00,
306 /// 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x07, 0x18
307 /// ];
308 ///
309 /// let pin2: [u8; 32] = [
310 /// 0xf0, 0xe1, 0xd2, 0xc3, 0xb4, 0xa5, 0x96, 0x87,
311 /// 0x78, 0x69, 0x5a, 0x4b, 0x3c, 0x2d, 0x1e, 0x0f,
312 /// 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
313 /// 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff
314 /// ];
315 ///
316 /// // Requires: --no-default-features --features rustls
317 /// let client = HttpClient::builder()
318 /// .with_pinned_cert_sha256(vec![pin1, pin2])
319 /// .build();
320 /// ```
321 #[cfg(feature = "rustls")]
322 pub fn with_pinned_cert_sha256(mut self, pins: Vec<[u8; 32]>) -> Self {
323 self.pinned_cert_sha256 = Some(pins);
324 self
325 }
326
327 /// Finalize the configuration and build an [`HttpClient`].
328 pub fn build(self) -> HttpClient {
329 HttpClient {
330 timeout: self.timeout,
331 default_headers: self.default_headers,
332 #[cfg(feature = "insecure-dangerous")]
333 accept_invalid_certs: self.accept_invalid_certs,
334 root_ca_pem: self.root_ca_pem,
335 #[cfg(feature = "rustls")]
336 pinned_cert_sha256: self.pinned_cert_sha256,
337 }
338 }
339}
340
341/// Default construction uses builder defaults.
342impl Default for HttpClient {
343 fn default() -> Self {
344 Self::new()
345 }
346}
347
348/// Default builder state is secure and ergonomic.
349impl Default for HttpClientBuilder {
350 fn default() -> Self {
351 Self::new()
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn builder_default_builds() {
361 let _client = HttpClient::builder().build();
362 }
363
364 #[test]
365 fn builder_allows_timeout_and_headers() {
366 let mut headers = HashMap::new();
367 headers.insert("x-test".into(), "1".into());
368 let builder = HttpClient::builder()
369 .with_timeout(Duration::from_secs(5))
370 .with_default_headers(headers);
371 #[cfg(feature = "rustls")]
372 let builder = builder.with_root_ca_pem(b"-----BEGIN CERTIFICATE-----\n...");
373 let _client = builder.build();
374 }
375
376 #[cfg(feature = "insecure-dangerous")]
377 #[test]
378 fn builder_allows_insecure_when_feature_enabled() {
379 let _client = HttpClient::builder()
380 .insecure_accept_invalid_certs(true)
381 .build();
382 let _client2 = HttpClient::with_self_signed_certs();
383 }
384
385 #[cfg(not(target_arch = "wasm32"))]
386 #[test]
387 fn request_returns_ok_on_native() {
388 let client = HttpClient::builder().build();
389 let res = client.request("https://example.com");
390 assert!(res.is_ok());
391 }
392
393 #[cfg(all(feature = "rustls", not(target_arch = "wasm32")))]
394 #[test]
395 fn builder_allows_root_ca_file() {
396 use std::fs;
397 use std::io::Write;
398
399 // Create a temporary file with test certificate content
400 let temp_dir = std::env::temp_dir();
401 let cert_file = temp_dir.join("test-ca.pem");
402
403 let test_cert = b"-----BEGIN CERTIFICATE-----
404MIICxjCCAa4CAQAwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAe
405Fw0yNTA4MTQwMDAwMDBaFw0yNjA4MTQwMDAwMDBaMBIxEDAOBgNVBAMMB1Rlc3Qg
406Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDTest...
407-----END CERTIFICATE-----";
408
409 // Write test certificate to temporary file
410 {
411 let mut file = fs::File::create(&cert_file).expect("Failed to create temp cert file");
412 file.write_all(test_cert)
413 .expect("Failed to write cert to temp file");
414 }
415
416 // Test that the builder can read the certificate file
417 let client = HttpClient::builder().with_root_ca_file(&cert_file).build();
418
419 // Verify the certificate was loaded
420 assert!(client.root_ca_pem.is_some());
421 assert_eq!(client.root_ca_pem.as_ref().unwrap(), test_cert);
422
423 // Clean up
424 let _ = fs::remove_file(cert_file);
425 }
426}