1use std::net::{IpAddr, SocketAddr};
5
6use wire::AuthToken;
7
8#[derive(Debug, Clone)]
10pub struct ClientConfig {
11 pub client_id: String,
13 pub token: Option<AuthToken>,
15 pub auth_proof_key_pem: Option<String>,
17 pub mint_root_attachment: Option<Vec<u8>>,
19 pub authenticated_principal: Option<String>,
23 pub server_key: Option<String>,
26 pub descriptor_key_id: Option<String>,
28 pub descriptor_public_key: Option<[u8; 32]>,
30 pub preferred_region: Option<String>,
32 pub tls_enabled: bool,
34 pub tls_domain_name: Option<String>,
36 pub tls_ca_certificate_pem: Option<String>,
38 pub tls_skip_verify: bool,
40 pub allow_insecure: bool,
46 pub timeout_secs: u64,
48 pub compression: bool,
50 pub chunk_size: usize,
52 pub chunked_transfer: bool,
54 pub resumable_transfer: bool,
56 pub pack_transfer: bool,
58 pub partial_fetch: bool,
60 pub provider_global_concurrency: usize,
62 pub provider_per_endpoint_concurrency: usize,
64 pub provider_max_inflight_bytes: usize,
66 pub provider_stall_timeout_secs: u64,
68}
69
70impl ClientConfig {
71 pub fn new(client_id: impl Into<String>) -> Self {
73 Self {
74 client_id: client_id.into(),
75 token: None,
76 auth_proof_key_pem: None,
77 mint_root_attachment: None,
78 authenticated_principal: None,
79 server_key: None,
80 descriptor_key_id: None,
81 descriptor_public_key: None,
82 preferred_region: None,
83 tls_enabled: false,
84 tls_domain_name: None,
85 tls_ca_certificate_pem: None,
86 tls_skip_verify: false,
87 allow_insecure: false,
88 timeout_secs: 30,
89 compression: true,
90 chunk_size: 64 * 1024,
91 chunked_transfer: true,
92 resumable_transfer: true,
93 pack_transfer: true,
94 partial_fetch: true,
95 provider_global_concurrency: 4,
96 provider_per_endpoint_concurrency: 2,
97 provider_max_inflight_bytes: 8 * 1024 * 1024,
98 provider_stall_timeout_secs: 15,
99 }
100 }
101
102 pub fn with_token(mut self, token: AuthToken) -> Self {
104 self.token = Some(token);
105 self
106 }
107
108 pub fn with_auth_proof_key_pem(mut self, pem: impl Into<String>) -> Self {
110 self.auth_proof_key_pem = Some(pem.into());
111 self
112 }
113
114 pub fn with_authenticated_principal(mut self, principal: impl Into<String>) -> Self {
116 self.authenticated_principal = Some(principal.into());
117 self
118 }
119
120 pub fn with_server_key(mut self, key: impl Into<String>) -> Self {
122 self.server_key = Some(key.into());
123 self
124 }
125
126 pub fn with_descriptor_trust(
127 mut self,
128 key_id: impl Into<String>,
129 public_key: [u8; 32],
130 ) -> Self {
131 self.descriptor_key_id = Some(key_id.into());
132 self.descriptor_public_key = Some(public_key);
133 self
134 }
135
136 pub fn with_preferred_region(mut self, region: impl Into<String>) -> Self {
137 let region = region.into();
138 self.preferred_region = (!region.is_empty()).then_some(region);
139 self
140 }
141
142 pub fn with_tls(mut self, skip_verify: bool) -> Self {
144 self.tls_enabled = true;
145 self.tls_skip_verify = skip_verify;
146 self
147 }
148
149 pub fn with_tls_domain_name(mut self, domain_name: impl Into<String>) -> Self {
150 self.tls_domain_name = Some(domain_name.into());
151 self
152 }
153
154 pub fn with_tls_ca_certificate_pem(mut self, pem: impl Into<String>) -> Self {
155 self.tls_enabled = true;
156 self.tls_ca_certificate_pem = Some(pem.into());
157 self
158 }
159
160 pub fn with_timeout(mut self, secs: u64) -> Self {
162 self.timeout_secs = secs;
163 self
164 }
165
166 pub fn without_compression(mut self) -> Self {
168 self.compression = false;
169 self
170 }
171
172 pub fn with_chunk_size(mut self, size: usize) -> Self {
174 self.chunk_size = size;
175 self
176 }
177
178 pub fn with_chunked_transfer(mut self, enabled: bool) -> Self {
180 self.chunked_transfer = enabled;
181 self
182 }
183
184 pub fn with_resumable_transfer(mut self, enabled: bool) -> Self {
186 self.resumable_transfer = enabled;
187 self
188 }
189
190 pub fn with_pack_transfer(mut self, enabled: bool) -> Self {
192 self.pack_transfer = enabled;
193 self
194 }
195
196 pub fn with_partial_fetch(mut self, enabled: bool) -> Self {
198 self.partial_fetch = enabled;
199 self
200 }
201
202 pub fn with_provider_global_concurrency(mut self, concurrency: usize) -> Self {
204 self.provider_global_concurrency = concurrency.max(1);
205 self
206 }
207
208 pub fn with_provider_per_endpoint_concurrency(mut self, concurrency: usize) -> Self {
210 self.provider_per_endpoint_concurrency = concurrency.max(1);
211 self
212 }
213
214 pub fn with_provider_max_inflight_bytes(mut self, bytes: usize) -> Self {
216 self.provider_max_inflight_bytes = bytes.max(1);
217 self
218 }
219
220 pub fn with_provider_stall_timeout(mut self, secs: u64) -> Self {
222 self.provider_stall_timeout_secs = secs.max(1);
223 self
224 }
225
226 pub fn with_allow_insecure(mut self, allow: bool) -> Self {
228 self.allow_insecure = allow;
229 self
230 }
231}
232
233impl Default for ClientConfig {
234 fn default() -> Self {
235 Self::new("heddle-cli")
236 }
237}
238
239pub fn is_loopback_ip(ip: IpAddr) -> bool {
241 ip.is_loopback()
242}
243
244pub fn cleartext_connect_allowed(
250 addr: SocketAddr,
251 tls_enabled: bool,
252 allow_insecure: bool,
253) -> bool {
254 tls_enabled || is_loopback_ip(addr.ip()) || allow_insecure
255}
256
257pub fn cleartext_refused_message(addr: SocketAddr) -> String {
259 format!(
260 "refusing cleartext connection to non-loopback address {addr}; \
261enable TLS (remote.tls_enabled / HEDDLE_REMOTE_TLS) or pass --insecure \
262(or set remote.insecure=true / HEDDLE_REMOTE_INSECURE=1) for intentional cleartext \
263(e.g. VPN → VPS testing)"
264 )
265}
266
267#[cfg(test)]
268mod tests {
269 use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
270
271 use super::*;
272
273 #[test]
274 fn loopback_cleartext_allowed_without_insecure() {
275 let v4 = SocketAddr::from((Ipv4Addr::LOCALHOST, 8421));
276 let v6 = SocketAddr::from((Ipv6Addr::LOCALHOST, 8421));
277 assert!(cleartext_connect_allowed(v4, false, false));
278 assert!(cleartext_connect_allowed(v6, false, false));
279 }
280
281 #[test]
282 fn non_loopback_cleartext_requires_insecure() {
283 let addr = SocketAddr::from((Ipv4Addr::new(203, 0, 113, 10), 8421));
284 assert!(!cleartext_connect_allowed(addr, false, false));
285 assert!(cleartext_connect_allowed(addr, false, true));
286 assert!(cleartext_connect_allowed(addr, true, false));
287 }
288
289 #[test]
290 fn is_loopback_classifies_hosts() {
291 assert!(is_loopback_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
292 assert!(is_loopback_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
293 assert!(!is_loopback_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
294 }
295
296 #[test]
297 fn provider_scheduler_defaults_and_builders_are_explicit() {
298 let defaults = ClientConfig::default();
299 assert_eq!(defaults.provider_global_concurrency, 4);
300 assert_eq!(defaults.provider_per_endpoint_concurrency, 2);
301 assert_eq!(defaults.provider_max_inflight_bytes, 8 * 1024 * 1024);
302 assert_eq!(defaults.provider_stall_timeout_secs, 15);
303
304 let configured = ClientConfig::default()
305 .with_provider_global_concurrency(8)
306 .with_provider_per_endpoint_concurrency(3)
307 .with_provider_max_inflight_bytes(4 * 1024 * 1024)
308 .with_provider_stall_timeout(20);
309 assert_eq!(configured.provider_global_concurrency, 8);
310 assert_eq!(configured.provider_per_endpoint_concurrency, 3);
311 assert_eq!(configured.provider_max_inflight_bytes, 4 * 1024 * 1024);
312 assert_eq!(configured.provider_stall_timeout_secs, 20);
313 }
314}