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