1pub mod certs;
23pub mod client;
24pub mod cmc;
25pub mod config;
26pub mod enroll;
27pub mod kem;
28pub mod kra;
29pub mod pool;
30pub mod profiles;
31
32pub use certs::{CertFilter, CertInfo, RevocationReason};
33pub use client::DogtagClient;
34pub use cmc::CmcClient;
35pub use config::DogtagConfig;
36pub use enroll::{EnrollResult, EnrollStatus, ServerKeygenResult};
37pub use kra::{KeySearchEntry, KraClient};
38pub use pool::DogtagPool;
39pub use profiles::{ProfileConstraints, ProfileDetail, ProfileInfo};
40
41use thiserror::Error;
42
43#[derive(Debug, Error)]
45pub enum DogtagError {
46 #[error("HTTP request failed: {0}")]
48 HttpError(String),
49
50 #[error("Dogtag returned HTTP {status}: {body}")]
52 ApiError {
53 status: u16,
55 body: String,
57 },
58
59 #[error("Failed to parse response: {0}")]
61 ParseError(String),
62
63 #[error("Invalid configuration: {0}")]
65 ConfigError(String),
66
67 #[error("TLS error: {0}")]
69 TlsError(String),
70
71 #[error("I/O error: {0}")]
73 IoError(#[from] std::io::Error),
74
75 #[error("No healthy CA backend available")]
77 NoHealthyBackend,
78
79 #[error("Enrollment rejected: {reason}")]
81 EnrollmentRejected {
82 reason: String,
84 },
85
86 #[error("Enrollment pending: request_id={request_id}")]
88 EnrollmentPending {
89 request_id: String,
91 },
92
93 #[error("KRA error: {0}")]
95 KraError(String),
96}
97
98pub type DogtagResult<T> = Result<T, DogtagError>;
100
101pub(crate) fn truncate_str(s: &str, max_bytes: usize) -> &str {
102 if s.len() <= max_bytes {
103 return s;
104 }
105 let mut end = max_bytes;
106 while end > 0 && !s.is_char_boundary(end) {
107 end -= 1;
108 }
109 &s[..end]
110}
111
112pub(crate) async fn bounded_bytes(mut response: reqwest::Response) -> DogtagResult<Vec<u8>> {
114 const LIMIT: usize = 4 * 1024 * 1024;
115 if response.content_length().is_some_and(|n| n > LIMIT as u64) {
116 return Err(DogtagError::ParseError(
117 "backend response exceeds 4 MiB".into(),
118 ));
119 }
120 let mut bytes = Vec::new();
121 while let Some(chunk) = response
122 .chunk()
123 .await
124 .map_err(|e| DogtagError::HttpError(e.to_string()))?
125 {
126 if chunk.len() > LIMIT.saturating_sub(bytes.len()) {
127 return Err(DogtagError::ParseError(
128 "backend response exceeds 4 MiB".into(),
129 ));
130 }
131 bytes.extend_from_slice(&chunk);
132 }
133 Ok(bytes)
134}
135pub(crate) async fn bounded_text(response: reqwest::Response) -> DogtagResult<String> {
136 Ok(String::from_utf8_lossy(&bounded_bytes(response).await?).into_owned())
137}
138
139#[cfg(test)]
140mod review_regressions {
141 use super::*;
142 #[test]
143 fn tls_verification_is_the_default() {
144 let config: DogtagConfig = serde_json::from_value(serde_json::json!({
145 "ca_url":"https://ca.example.test", "agent_cert_file":"cert", "agent_key_file":"key", "ca_cert_file":"ca", "profile_id":"profile"
146 })).unwrap();
147 assert!(!config.accept_invalid_certs);
148 }
149
150 #[tokio::test]
151 async fn chunked_backend_response_is_bounded() {
152 use std::io::{Read, Write};
153 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
154 let address = listener.local_addr().unwrap();
155 let writer = std::thread::spawn(move || {
156 let (mut socket, _) = listener.accept().unwrap();
157 let mut request = [0; 4096];
158 let _ = socket.read(&mut request);
159 socket
160 .write_all(
161 b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n",
162 )
163 .unwrap();
164 let block = vec![b'x'; 65536];
165 for _ in 0..65 {
166 if socket
167 .write_all(b"10000\r\n")
168 .and_then(|_| socket.write_all(&block))
169 .and_then(|_| socket.write_all(b"\r\n"))
170 .is_err()
171 {
172 return;
173 }
174 }
175 let _ = socket.write_all(b"0\r\n\r\n");
176 });
177 let response = reqwest::get(format!("http://{address}")).await.unwrap();
178 assert!(
179 bounded_bytes(response)
180 .await
181 .unwrap_err()
182 .to_string()
183 .contains("exceeds 4 MiB")
184 );
185 writer.join().unwrap();
186 }
187}