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