1use std::{
3 collections::BTreeMap,
4 env, fs,
5 io::Read,
6 path::{Path, PathBuf},
7};
8
9use objects::fs_atomic::write_file_atomic_secret;
10use repo::{FsMonitorMode, FsMonitorSettings, OutputFormat, WorktreeStatusOptions};
11use serde::{Deserialize, Serialize};
12use wire::AuthToken;
13
14use crate::client_config::ClientConfig;
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct UserConfig {
18 #[serde(default)]
19 pub principal: Option<UserPrincipalConfig>,
20 #[serde(default)]
21 pub agent: UserAgentConfig,
22 #[serde(default)]
23 pub capture: UserCaptureConfig,
24 #[serde(default)]
25 pub output: UserOutputConfig,
26 #[serde(default)]
27 pub display: UserDisplayConfig,
28 #[serde(default)]
29 pub worktree: UserWorktreeConfig,
30 #[serde(default)]
31 pub logging: UserLoggingConfig,
32 #[serde(default)]
33 pub remote: UserRemoteConfig,
34 #[serde(default)]
35 pub harness: UserHarnessConfig,
36 #[serde(default)]
37 pub land: UserLandConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct UserPrincipalConfig {
42 pub name: String,
43 pub email: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47pub struct UserAgentConfig {
48 #[serde(default)]
49 pub provider: Option<String>,
50 #[serde(default)]
51 pub model: Option<String>,
52 #[serde(default)]
53 pub default_policy: Option<String>,
54 #[serde(default = "default_confidence")]
55 pub confidence: f32,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct UserCaptureConfig {
60 #[serde(default)]
61 pub auto: UserAutoCaptureMode,
62}
63
64#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
65#[serde(rename_all = "lowercase")]
66pub enum UserAutoCaptureMode {
67 #[default]
68 Off,
69 Command,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct UserOutputConfig {
74 #[serde(default)]
75 pub format: OutputFormat,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct UserDisplayConfig {
80 #[serde(default = "default_hash_length")]
81 pub hash_length: usize,
82 #[serde(default = "default_change_id_format")]
83 pub change_id_format: String,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
87pub struct UserWorktreeConfig {
88 #[serde(default)]
89 pub fsmonitor: UserFsMonitorConfig,
90 #[serde(default)]
91 pub thread_workspace: UserThreadWorkspaceConfig,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct UserFsMonitorConfig {
96 #[serde(default)]
97 pub mode: Option<FsMonitorMode>,
98}
99
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
105#[serde(rename_all = "kebab-case")]
106pub enum UserThreadWorkspaceMode {
107 #[default]
108 Auto,
109 Materialized,
110 Virtualized,
111 Solid,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct UserThreadWorkspaceConfig {
116 #[serde(default)]
117 pub top_level_default: UserThreadWorkspaceMode,
118 #[serde(default)]
119 pub delegated_default: Option<UserThreadWorkspaceMode>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
123pub struct UserLoggingConfig {
124 #[serde(default)]
125 pub format: Option<String>,
126 #[serde(default)]
127 pub include_location: bool,
128 #[serde(default)]
129 pub include_thread_ids: bool,
130 #[serde(default)]
131 pub log_spans: bool,
132 #[serde(default)]
133 pub otel_service_name: Option<String>,
134 #[serde(default)]
135 pub otel_endpoint: Option<String>,
136 #[serde(default)]
137 pub otel_traces_endpoint: Option<String>,
138 #[serde(default)]
139 pub otel_metrics_endpoint: Option<String>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default)]
143pub struct UserRemoteConfig {
144 #[serde(default)]
145 pub token: Option<String>,
146 #[serde(default)]
147 pub tls_enabled: bool,
148 #[serde(default)]
149 pub tls_domain_name: Option<String>,
150 #[serde(default)]
151 pub tls_ca_certificate_path: Option<PathBuf>,
152 #[serde(default)]
153 pub auth_proof_key_pem_path: Option<PathBuf>,
154 #[serde(default)]
157 pub insecure: bool,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct UserLandConfig {
162 #[serde(default = "default_land_squash")]
163 pub squash: bool,
164}
165
166#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
167#[serde(rename_all = "lowercase")]
168pub enum HarnessMode {
169 #[default]
170 Auto,
171 Off,
172 Required,
173}
174
175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
176#[serde(rename_all = "lowercase")]
177pub enum HarnessTransport {
178 #[default]
179 Spool,
180 Direct,
181 End,
182}
183
184#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
185#[serde(rename_all = "lowercase")]
186pub enum HarnessTranscriptMode {
187 #[default]
188 Off,
189 Summary,
190 Full,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, Default)]
194pub struct UserHarnessOverride {
195 #[serde(default)]
196 pub provider: Option<String>,
197 #[serde(default)]
198 pub model: Option<String>,
199 #[serde(default)]
200 pub thinking_level: Option<String>,
201 #[serde(default)]
202 pub policy: Option<String>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct UserHarnessConfig {
207 #[serde(default)]
208 pub mode: HarnessMode,
209 #[serde(default)]
210 pub transport: HarnessTransport,
211 #[serde(default)]
212 pub transcript: HarnessTranscriptMode,
213 #[serde(default = "default_auto_infer")]
214 pub auto_infer: bool,
215 #[serde(default)]
216 pub threading: UserHarnessThreadingConfig,
217 #[serde(default)]
218 pub harnesses: BTreeMap<String, UserHarnessOverride>,
219}
220
221#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
222#[serde(rename_all = "kebab-case")]
223pub enum UserHarnessRootThreadPolicy {
224 CreateNew,
225 #[default]
226 AttachCurrent,
227}
228
229#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
230#[serde(rename_all = "kebab-case")]
231pub enum UserHarnessSubagentThreadPolicy {
232 AttachCurrent,
233 #[default]
234 CreateChild,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, Default)]
238pub struct UserHarnessThreadingConfig {
239 #[serde(default)]
240 pub root_actor: UserHarnessRootThreadPolicy,
241 #[serde(default)]
242 pub subagent: UserHarnessSubagentThreadPolicy,
243 #[serde(default)]
244 pub workspace_default: Option<UserThreadWorkspaceMode>,
245}
246
247fn default_confidence() -> f32 {
248 0.8
249}
250
251fn default_hash_length() -> usize {
252 8
253}
254
255fn default_change_id_format() -> String {
256 "short".to_string()
257}
258
259fn default_auto_infer() -> bool {
260 true
261}
262
263fn default_land_squash() -> bool {
264 true
265}
266
267impl Default for UserDisplayConfig {
268 fn default() -> Self {
269 Self {
270 hash_length: default_hash_length(),
271 change_id_format: default_change_id_format(),
272 }
273 }
274}
275
276impl Default for UserHarnessConfig {
277 fn default() -> Self {
278 Self {
279 mode: HarnessMode::Auto,
280 transport: HarnessTransport::Spool,
281 transcript: HarnessTranscriptMode::Off,
282 auto_infer: default_auto_infer(),
283 threading: UserHarnessThreadingConfig::default(),
284 harnesses: BTreeMap::new(),
285 }
286 }
287}
288
289impl Default for UserLandConfig {
290 fn default() -> Self {
291 Self {
292 squash: default_land_squash(),
293 }
294 }
295}
296
297impl UserConfig {
298 pub fn default_path() -> Option<PathBuf> {
299 if let Ok(path) = std::env::var("HEDDLE_CONFIG")
300 && !path.is_empty()
301 {
302 return Some(PathBuf::from(path));
303 }
304 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
305 && !xdg.is_empty()
306 {
307 return Some(PathBuf::from(xdg).join("heddle").join("config.toml"));
308 }
309 if let Ok(home) = std::env::var("HOME")
310 && !home.is_empty()
311 {
312 return Some(PathBuf::from(home).join(".config/heddle/config.toml"));
313 }
314 None
315 }
316
317 pub fn load(path: &Path) -> anyhow::Result<Self> {
318 let mut file = fs::File::open(path)?;
319 let mut contents = String::new();
320 file.read_to_string(&mut contents)?;
321 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
322 if let Some(value) = invalid_output_format_value(&contents) {
323 return Err(objects::error::HeddleError::ConfigInvalidValue {
324 path: resolved,
325 key: "output.format".to_string(),
326 value,
327 valid_values: vec!["'text'".to_string(), "'json'".to_string()],
328 }
329 .into());
330 }
331 toml::from_str::<Self>(&contents).map_err(|err| {
339 objects::error::HeddleError::ConfigParse {
340 path: resolved,
341 source: err,
342 }
343 .into()
344 })
345 }
346
347 pub fn load_default() -> anyhow::Result<Self> {
348 match Self::default_path() {
349 Some(path) => match Self::load(&path) {
350 Ok(config) => Ok(config),
351 Err(err) if path_missing(&err) => Ok(Self::default()),
352 Err(err) => Err(err),
353 },
354 None => Ok(Self::default()),
355 }
356 }
357
358 pub fn save_default(&self) -> anyhow::Result<PathBuf> {
359 let path = Self::default_path()
360 .ok_or_else(|| anyhow::anyhow!("unable to determine user config path"))?;
361 self.save(&path)?;
362 Ok(path)
363 }
364
365 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
366 if let Some(parent) = path.parent() {
367 fs::create_dir_all(parent)?;
368 }
369 let contents = toml::to_string_pretty(self)?;
370 write_file_atomic_secret(path, contents.as_bytes())?;
371 Ok(())
372 }
373
374 pub fn set_principal(&mut self, name: impl Into<String>, email: impl Into<String>) {
375 self.principal = Some(UserPrincipalConfig {
376 name: name.into(),
377 email: email.into(),
378 });
379 }
380
381 pub fn remote_token(&self) -> anyhow::Result<Option<AuthToken>> {
382 match env::var("HEDDLE_REMOTE_TOKEN") {
383 Ok(token) if !token.is_empty() => Ok(Some(AuthToken::new(token, "env"))),
384 Ok(_) | Err(env::VarError::NotPresent) => Ok(self
385 .remote
386 .token
387 .clone()
388 .map(|token| AuthToken::new(token, "user-config"))),
389 Err(err @ env::VarError::NotUnicode(_)) => Err(security_config_error(
390 "HEDDLE_REMOTE_TOKEN",
391 format!("read environment value: {err}"),
392 )),
393 }
394 }
395
396 pub fn command_auto_capture_enabled(&self) -> anyhow::Result<bool> {
397 let mut mode = self.capture.auto;
398 match env::var("HEDDLE_AUTO_CAPTURE") {
399 Ok(value) if !value.trim().is_empty() => {
400 mode = parse_auto_capture_env("HEDDLE_AUTO_CAPTURE", &value)?;
401 }
402 Ok(_) | Err(env::VarError::NotPresent) => {}
403 Err(err @ env::VarError::NotUnicode(_)) => {
404 return Err(config_value_error(
405 "HEDDLE_AUTO_CAPTURE",
406 format!("read environment value: {err}"),
407 ));
408 }
409 }
410 Ok(matches!(mode, UserAutoCaptureMode::Command))
411 }
412
413 pub fn heddle_client_config(
414 &self,
415 token_override: Option<AuthToken>,
416 ) -> anyhow::Result<ClientConfig> {
417 let token = match token_override {
418 Some(token) => Some(token),
419 None => self.remote_token()?,
420 };
421 let mut config = token
422 .map(|token| ClientConfig::default().with_token(token))
423 .unwrap_or_default();
424
425 if self.remote.tls_enabled {
426 config = config.with_tls(false);
427 }
428 if self.remote.insecure {
429 config = config.with_allow_insecure(true);
430 }
431 if let Some(domain) = &self.remote.tls_domain_name {
432 config = config.with_tls_domain_name(domain.clone());
433 }
434 if let Some(path) = &self.remote.tls_ca_certificate_path {
435 let pem = read_security_config_file("remote.tls_ca_certificate_path", path)?;
436 config = config.with_tls_ca_certificate_pem(pem);
437 }
438 if let Some(path) = &self.remote.auth_proof_key_pem_path {
439 let pem = read_security_config_file("remote.auth_proof_key_pem_path", path)?;
440 config = config.with_auth_proof_key_pem(pem);
441 }
442
443 if env_bool("HEDDLE_REMOTE_TLS")? {
444 config = config.with_tls(false);
445 }
446 if env_bool("HEDDLE_REMOTE_INSECURE")? {
447 config = config.with_allow_insecure(true);
448 }
449 match env::var("HEDDLE_REMOTE_TLS_DOMAIN") {
450 Ok(domain) => config = config.with_tls_domain_name(domain),
451 Err(env::VarError::NotPresent) => {}
452 Err(err @ env::VarError::NotUnicode(_)) => {
453 return Err(security_config_error(
454 "HEDDLE_REMOTE_TLS_DOMAIN",
455 format!("read environment value: {err}"),
456 ));
457 }
458 }
459 match env::var("HEDDLE_REMOTE_TLS_CA_CERT") {
460 Ok(path) => {
461 let pem =
462 read_security_config_file("HEDDLE_REMOTE_TLS_CA_CERT", &PathBuf::from(path))?;
463 config = config.with_tls_ca_certificate_pem(pem);
464 }
465 Err(env::VarError::NotPresent) => {}
466 Err(err @ env::VarError::NotUnicode(_)) => {
467 return Err(security_config_error(
468 "HEDDLE_REMOTE_TLS_CA_CERT",
469 format!("read environment value: {err}"),
470 ));
471 }
472 }
473 Ok(config)
474 }
475
476 pub fn worktree_status_options(
477 &self,
478 repo_config: Option<&repo::RepoConfig>,
479 ) -> WorktreeStatusOptions {
480 let mut mode = self
481 .worktree
482 .fsmonitor
483 .mode
484 .or_else(|| repo_config.map(|config| config.worktree.fsmonitor.mode))
485 .unwrap_or(FsMonitorMode::Off);
486 if let Ok(value) = std::env::var("HEDDLE_FSMONITOR")
487 && let Some(parsed) = FsMonitorMode::parse(&value)
488 {
489 mode = parsed;
490 }
491
492 WorktreeStatusOptions {
493 fsmonitor: FsMonitorSettings { mode },
494 }
495 }
496}
497
498fn parse_auto_capture_env(setting: &str, value: &str) -> anyhow::Result<UserAutoCaptureMode> {
499 match value.trim().to_ascii_lowercase().as_str() {
500 "1" | "true" | "yes" | "on" | "command" | "commands" => Ok(UserAutoCaptureMode::Command),
501 "0" | "false" | "no" | "off" => Ok(UserAutoCaptureMode::Off),
502 _ => Err(config_value_error(
503 setting,
504 format!(
505 "parse auto-capture value {value:?}; expected one of off, command, true, or false"
506 ),
507 )),
508 }
509}
510
511fn invalid_output_format_value(contents: &str) -> Option<String> {
512 let value = toml::from_str::<toml::Value>(contents).ok()?;
513 let format = value
514 .get("output")
515 .and_then(|output| output.get("format"))
516 .and_then(toml::Value::as_str)?;
517 (!matches!(format, "text" | "json")).then(|| format.to_string())
518}
519
520fn read_security_config_file(setting: &str, path: &Path) -> anyhow::Result<String> {
521 fs::read_to_string(path).map_err(|err| {
522 security_config_error(
523 setting,
524 format!("read configured file {}: {err}", path.display()),
525 )
526 })
527}
528
529fn env_bool(name: &str) -> anyhow::Result<bool> {
530 let value = match env::var(name) {
531 Ok(value) => value,
532 Err(env::VarError::NotPresent) => return Ok(false),
533 Err(err @ env::VarError::NotUnicode(_)) => {
534 return Err(security_config_error(
535 name,
536 format!("read environment value: {err}"),
537 ));
538 }
539 };
540 match value.trim().to_ascii_lowercase().as_str() {
541 "1" | "true" | "yes" | "on" => Ok(true),
542 "0" | "false" | "no" | "off" => Ok(false),
543 _ => Err(security_config_error(
544 name,
545 format!(
546 "parse boolean value {value:?}; expected one of 1/0, true/false, yes/no, or on/off"
547 ),
548 )),
549 }
550}
551
552fn config_value_error(setting: &str, reason: String) -> anyhow::Error {
553 anyhow::anyhow!("fatal configuration error for `{setting}`: {reason}")
554}
555
556fn security_config_error(setting: &str, reason: String) -> anyhow::Error {
557 anyhow::anyhow!(
558 "fatal TLS/auth configuration error for `{setting}`: {reason}; refusing to proceed with an ambiguous security posture"
559 )
560}
561
562fn path_missing(err: &anyhow::Error) -> bool {
563 err.downcast_ref::<std::io::Error>()
564 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
565}
566
567#[cfg(test)]
568mod tests {
569 use std::{
570 ffi::OsString,
571 fs,
572 path::PathBuf,
573 sync::MutexGuard,
574 time::{SystemTime, UNIX_EPOCH},
575 };
576
577 use repo::{FsMonitorMode, RepoConfig};
578
579 use super::{
580 HarnessMode, HarnessTranscriptMode, HarnessTransport, UserAutoCaptureMode,
581 UserCaptureConfig, UserConfig, UserRemoteConfig,
582 };
583
584 static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
585 const REMOTE_ENV_KEYS: &[&str] = &[
586 "HEDDLE_REMOTE_TOKEN",
587 "HEDDLE_REMOTE_TLS",
588 "HEDDLE_REMOTE_TLS_DOMAIN",
589 "HEDDLE_REMOTE_TLS_CA_CERT",
590 "HEDDLE_REMOTE_INSECURE",
591 "HEDDLE_AUTO_CAPTURE",
592 ];
593
594 struct RemoteEnvGuard {
595 _guard: MutexGuard<'static, ()>,
596 saved: Vec<(&'static str, Option<OsString>)>,
597 }
598
599 impl RemoteEnvGuard {
600 fn clean() -> Self {
601 let guard = TEST_ENV_LOCK
602 .lock()
603 .unwrap_or_else(|poisoned| poisoned.into_inner());
604 let saved = REMOTE_ENV_KEYS
605 .iter()
606 .map(|key| (*key, std::env::var_os(key)))
607 .collect();
608 for key in REMOTE_ENV_KEYS {
609 unsafe { std::env::remove_var(key) };
610 }
611 Self {
612 _guard: guard,
613 saved,
614 }
615 }
616
617 fn set(&self, key: &str, value: impl AsRef<std::ffi::OsStr>) {
618 unsafe { std::env::set_var(key, value) };
619 }
620 }
621
622 impl Drop for RemoteEnvGuard {
623 fn drop(&mut self) {
624 for (key, value) in &self.saved {
625 unsafe {
626 if let Some(value) = value {
627 std::env::set_var(key, value);
628 } else {
629 std::env::remove_var(key);
630 }
631 }
632 }
633 }
634 }
635
636 fn unique_temp_path(prefix: &str) -> PathBuf {
637 let unique = SystemTime::now()
638 .duration_since(UNIX_EPOCH)
639 .expect("system time before unix epoch")
640 .as_nanos();
641 std::env::temp_dir().join(format!("{prefix}-{}-{unique}", std::process::id()))
642 }
643
644 #[test]
645 fn user_worktree_status_options_fall_back_to_repo_config() {
646 let mut repo = RepoConfig::default();
647 repo.worktree.fsmonitor.mode = FsMonitorMode::Watchman;
648
649 let config = UserConfig::default();
650 let options = config.worktree_status_options(Some(&repo));
651
652 assert_eq!(options.fsmonitor.mode, FsMonitorMode::Watchman);
653 }
654
655 #[test]
656 fn harness_config_defaults_are_magical_but_safe() {
657 let config = UserConfig::default();
658 assert_eq!(config.harness.mode, HarnessMode::Auto);
659 assert_eq!(config.harness.transport, HarnessTransport::Spool);
660 assert_eq!(config.harness.transcript, HarnessTranscriptMode::Off);
661 assert!(config.harness.auto_infer);
662 assert!(config.harness.harnesses.is_empty());
663 }
664
665 #[test]
666 fn command_auto_capture_defaults_off() {
667 let _env = RemoteEnvGuard::clean();
668
669 let config = UserConfig::default();
670
671 assert!(!config.command_auto_capture_enabled().unwrap());
672 }
673
674 #[test]
675 fn command_auto_capture_reads_user_config() {
676 let _env = RemoteEnvGuard::clean();
677 let config = UserConfig {
678 capture: UserCaptureConfig {
679 auto: UserAutoCaptureMode::Command,
680 },
681 ..UserConfig::default()
682 };
683
684 assert!(config.command_auto_capture_enabled().unwrap());
685 }
686
687 #[test]
688 fn command_auto_capture_env_overrides_user_config() {
689 let env = RemoteEnvGuard::clean();
690 env.set("HEDDLE_AUTO_CAPTURE", "off");
691 let config = UserConfig {
692 capture: UserCaptureConfig {
693 auto: UserAutoCaptureMode::Command,
694 },
695 ..UserConfig::default()
696 };
697
698 assert!(!config.command_auto_capture_enabled().unwrap());
699
700 env.set("HEDDLE_AUTO_CAPTURE", "command");
701 assert!(
702 UserConfig::default()
703 .command_auto_capture_enabled()
704 .unwrap()
705 );
706 }
707
708 #[test]
709 fn user_config_toml_parses_capture_auto_command() {
710 let parsed: UserConfig = toml::from_str(
711 r#"
712 [capture]
713 auto = "command"
714 "#,
715 )
716 .expect("capture auto config should parse");
717
718 assert_eq!(parsed.capture.auto, UserAutoCaptureMode::Command);
719 }
720
721 #[test]
722 fn heddle_client_config_absent_security_settings_uses_defaults() {
723 let _env = RemoteEnvGuard::clean();
724 let config = UserConfig::default()
725 .heddle_client_config(None)
726 .expect("absent optional settings should not error");
727
728 assert!(!config.tls_enabled);
729 assert!(!config.tls_skip_verify);
730 assert!(config.tls_ca_certificate_pem.is_none());
731 assert!(config.auth_proof_key_pem.is_none());
732 assert!(config.token.is_none());
733 }
734
735 #[test]
736 fn heddle_client_config_valid_security_files_are_applied() {
737 let _env = RemoteEnvGuard::clean();
738 let dir = unique_temp_path("heddle-user-config-valid-security");
739 fs::create_dir_all(&dir).expect("create temp dir");
740 let ca_path = dir.join("ca.pem");
741 let key_path = dir.join("proof-key.pem");
742 fs::write(&ca_path, "test ca pem").expect("write ca pem");
743 fs::write(&key_path, "test key pem").expect("write key pem");
744 let user = UserConfig {
745 remote: UserRemoteConfig {
746 tls_ca_certificate_path: Some(ca_path),
747 auth_proof_key_pem_path: Some(key_path),
748 ..UserRemoteConfig::default()
749 },
750 ..UserConfig::default()
751 };
752
753 let config = user
754 .heddle_client_config(None)
755 .expect("valid TLS/auth files should load");
756
757 assert!(config.tls_enabled);
758 assert_eq!(
759 config.tls_ca_certificate_pem.as_deref(),
760 Some("test ca pem")
761 );
762 assert_eq!(config.auth_proof_key_pem.as_deref(), Some("test key pem"));
763
764 fs::remove_dir_all(dir).expect("remove temp dir");
765 }
766
767 #[test]
768 fn heddle_client_config_missing_tls_ca_path_fails_closed() {
769 let _env = RemoteEnvGuard::clean();
770 let missing = unique_temp_path("heddle-user-config-missing-ca").join("ca.pem");
771 let user = UserConfig {
772 remote: UserRemoteConfig {
773 tls_ca_certificate_path: Some(missing),
774 ..UserRemoteConfig::default()
775 },
776 ..UserConfig::default()
777 };
778
779 let err = user
780 .heddle_client_config(None)
781 .expect_err("missing configured CA path must fail closed");
782 let message = err.to_string();
783
784 assert!(message.contains("fatal TLS/auth configuration error"));
785 assert!(message.contains("remote.tls_ca_certificate_path"));
786 }
787
788 #[test]
789 fn heddle_client_config_missing_auth_proof_key_path_fails_closed() {
790 let _env = RemoteEnvGuard::clean();
791 let missing = unique_temp_path("heddle-user-config-missing-key").join("proof-key.pem");
792 let user = UserConfig {
793 remote: UserRemoteConfig {
794 auth_proof_key_pem_path: Some(missing),
795 ..UserRemoteConfig::default()
796 },
797 ..UserConfig::default()
798 };
799
800 let err = user
801 .heddle_client_config(None)
802 .expect_err("missing configured proof key path must fail closed");
803 let message = err.to_string();
804
805 assert!(message.contains("fatal TLS/auth configuration error"));
806 assert!(message.contains("remote.auth_proof_key_pem_path"));
807 }
808
809 #[test]
810 fn heddle_client_config_missing_env_tls_ca_path_fails_closed() {
811 let env = RemoteEnvGuard::clean();
812 let missing = unique_temp_path("heddle-user-config-missing-env-ca").join("ca.pem");
813 env.set("HEDDLE_REMOTE_TLS_CA_CERT", missing);
814
815 let err = UserConfig::default()
816 .heddle_client_config(None)
817 .expect_err("missing env CA path must fail closed");
818 let message = err.to_string();
819
820 assert!(message.contains("fatal TLS/auth configuration error"));
821 assert!(message.contains("HEDDLE_REMOTE_TLS_CA_CERT"));
822 }
823
824 #[test]
825 fn heddle_client_config_invalid_env_tls_value_fails_closed() {
826 let env = RemoteEnvGuard::clean();
827 env.set("HEDDLE_REMOTE_TLS", "enabled");
828
829 let err = UserConfig::default()
830 .heddle_client_config(None)
831 .expect_err("invalid TLS env value must fail closed");
832 let message = err.to_string();
833
834 assert!(message.contains("fatal TLS/auth configuration error"));
835 assert!(message.contains("HEDDLE_REMOTE_TLS"));
836 }
837}