1use std::{
3 collections::BTreeMap,
4 env, fs,
5 io::Read,
6 path::{Path, PathBuf},
7 sync::OnceLock,
8};
9
10use objects::{
11 fs_atomic::{StagedAtomicWrite, stage_file_atomic_secret},
12 object::Principal,
13};
14use repo::{
15 FsMonitorMode, FsMonitorSettings, OutputFormat, Repository, WorktreeStatusOptions,
16 identity::heddle_home_override,
17};
18use serde::{Deserialize, Serialize};
19use wire::AuthToken;
20
21use crate::client_config::ClientConfig;
22
23static REMOTE_TLS_REPO_START: OnceLock<PathBuf> = OnceLock::new();
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct UserConfig {
30 #[serde(default)]
31 pub principal: Option<UserPrincipalConfig>,
32 #[serde(default)]
33 pub agent: UserAgentConfig,
34 #[serde(default)]
35 pub capture: UserCaptureConfig,
36 #[serde(default)]
37 pub output: UserOutputConfig,
38 #[serde(default)]
39 pub display: UserDisplayConfig,
40 #[serde(default)]
41 pub worktree: UserWorktreeConfig,
42 #[serde(default)]
43 pub logging: UserLoggingConfig,
44 #[serde(default)]
45 pub remote: UserRemoteConfig,
46 #[serde(default)]
47 pub harness: UserHarnessConfig,
48 #[serde(default)]
49 pub land: UserLandConfig,
50}
51
52pub struct StagedUserConfig {
53 path: PathBuf,
54 write: StagedAtomicWrite,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ResolvedPrincipal {
60 pub principal: Principal,
61 pub source: Option<&'static str>,
62}
63
64impl ResolvedPrincipal {
65 fn configured(principal: Principal, source: &'static str) -> Self {
66 Self {
67 principal,
68 source: Some(source),
69 }
70 }
71
72 fn unknown(principal: Principal) -> Self {
73 Self {
74 principal,
75 source: None,
76 }
77 }
78}
79
80impl StagedUserConfig {
81 pub fn publish(self) -> anyhow::Result<PathBuf> {
82 self.write.publish()?;
83 Ok(self.path)
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct UserPrincipalConfig {
89 pub name: String,
90 pub email: String,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, Default)]
94pub struct UserAgentConfig {
95 #[serde(default)]
96 pub provider: Option<String>,
97 #[serde(default)]
98 pub model: Option<String>,
99 #[serde(default)]
100 pub default_policy: Option<String>,
101 #[serde(default = "default_confidence")]
102 pub confidence: f32,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
106pub struct UserCaptureConfig {
107 #[serde(default)]
108 pub auto: UserAutoCaptureMode,
109}
110
111#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
112#[serde(rename_all = "lowercase")]
113pub enum UserAutoCaptureMode {
114 #[default]
115 Off,
116 Command,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120pub struct UserOutputConfig {
121 #[serde(default)]
122 pub format: OutputFormat,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct UserDisplayConfig {
127 #[serde(default = "default_hash_length")]
128 pub hash_length: usize,
129 #[serde(default = "default_change_id_format")]
130 pub change_id_format: String,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, Default)]
134pub struct UserWorktreeConfig {
135 #[serde(default)]
136 pub fsmonitor: UserFsMonitorConfig,
137 #[serde(default)]
138 pub thread_workspace: UserThreadWorkspaceConfig,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142pub struct UserFsMonitorConfig {
143 #[serde(default)]
144 pub mode: Option<FsMonitorMode>,
145}
146
147#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
152#[serde(rename_all = "kebab-case")]
153pub enum UserThreadWorkspaceMode {
154 #[default]
155 Auto,
156 Materialized,
157 Virtualized,
158 Solid,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, Default)]
162pub struct UserThreadWorkspaceConfig {
163 #[serde(default)]
164 pub top_level_default: UserThreadWorkspaceMode,
165 #[serde(default)]
166 pub delegated_default: Option<UserThreadWorkspaceMode>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, Default)]
170pub struct UserLoggingConfig {
171 #[serde(default)]
172 pub format: Option<String>,
173 #[serde(default)]
174 pub include_location: bool,
175 #[serde(default)]
176 pub include_thread_ids: bool,
177 #[serde(default)]
178 pub log_spans: bool,
179 #[serde(default)]
180 pub otel_service_name: Option<String>,
181 #[serde(default)]
182 pub otel_endpoint: Option<String>,
183 #[serde(default)]
184 pub otel_traces_endpoint: Option<String>,
185 #[serde(default)]
186 pub otel_metrics_endpoint: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, Default)]
190pub struct UserRemoteConfig {
191 #[serde(default)]
192 pub tls_enabled: bool,
193 #[serde(default)]
194 pub tls_domain_name: Option<String>,
195 #[serde(default)]
196 pub tls_ca_certificate_path: Option<PathBuf>,
197 #[serde(default)]
198 pub auth_proof_key_pem_path: Option<PathBuf>,
199 #[serde(default)]
200 pub iroh_descriptor_key_id: Option<String>,
201 #[serde(default)]
202 pub iroh_descriptor_public_key_path: Option<PathBuf>,
203 #[serde(default)]
204 pub provider_global_concurrency: Option<usize>,
205 #[serde(default)]
206 pub provider_per_endpoint_concurrency: Option<usize>,
207 #[serde(default)]
208 pub provider_max_inflight_bytes: Option<usize>,
209 #[serde(default)]
210 pub provider_stall_timeout_secs: Option<u64>,
211 #[serde(default)]
214 pub insecure: bool,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct UserLandConfig {
219 #[serde(default = "default_land_squash")]
220 pub squash: bool,
221}
222
223#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
224#[serde(rename_all = "lowercase")]
225pub enum HarnessMode {
226 #[default]
227 Auto,
228 Off,
229 Required,
230}
231
232#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
233#[serde(rename_all = "lowercase")]
234pub enum HarnessTransport {
235 #[default]
236 Spool,
237 Direct,
238 End,
239}
240
241#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
242#[serde(rename_all = "lowercase")]
243pub enum HarnessTranscriptMode {
244 #[default]
245 Off,
246 Summary,
247 Full,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, Default)]
251pub struct UserHarnessOverride {
252 #[serde(default)]
253 pub provider: Option<String>,
254 #[serde(default)]
255 pub model: Option<String>,
256 #[serde(default)]
257 pub thinking_level: Option<String>,
258 #[serde(default)]
259 pub policy: Option<String>,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct UserHarnessConfig {
264 #[serde(default)]
265 pub mode: HarnessMode,
266 #[serde(default)]
267 pub transport: HarnessTransport,
268 #[serde(default)]
269 pub transcript: HarnessTranscriptMode,
270 #[serde(default = "default_auto_infer")]
271 pub auto_infer: bool,
272 #[serde(default)]
273 pub threading: UserHarnessThreadingConfig,
274 #[serde(default)]
275 pub harnesses: BTreeMap<String, UserHarnessOverride>,
276}
277
278#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
279#[serde(rename_all = "kebab-case")]
280pub enum UserHarnessRootThreadPolicy {
281 CreateNew,
282 #[default]
283 AttachCurrent,
284}
285
286#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
287#[serde(rename_all = "kebab-case")]
288pub enum UserHarnessSubagentThreadPolicy {
289 AttachCurrent,
290 #[default]
291 CreateChild,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, Default)]
295pub struct UserHarnessThreadingConfig {
296 #[serde(default)]
297 pub root_actor: UserHarnessRootThreadPolicy,
298 #[serde(default)]
299 pub subagent: UserHarnessSubagentThreadPolicy,
300 #[serde(default)]
301 pub workspace_default: Option<UserThreadWorkspaceMode>,
302}
303
304fn default_confidence() -> f32 {
305 0.8
306}
307
308fn default_hash_length() -> usize {
309 8
310}
311
312fn default_change_id_format() -> String {
313 "short".to_string()
314}
315
316fn default_auto_infer() -> bool {
317 true
318}
319
320fn default_land_squash() -> bool {
321 true
322}
323
324impl Default for UserDisplayConfig {
325 fn default() -> Self {
326 Self {
327 hash_length: default_hash_length(),
328 change_id_format: default_change_id_format(),
329 }
330 }
331}
332
333impl Default for UserHarnessConfig {
334 fn default() -> Self {
335 Self {
336 mode: HarnessMode::Auto,
337 transport: HarnessTransport::Spool,
338 transcript: HarnessTranscriptMode::Off,
339 auto_infer: default_auto_infer(),
340 threading: UserHarnessThreadingConfig::default(),
341 harnesses: BTreeMap::new(),
342 }
343 }
344}
345
346impl Default for UserLandConfig {
347 fn default() -> Self {
348 Self {
349 squash: default_land_squash(),
350 }
351 }
352}
353
354impl UserConfig {
355 pub fn default_path() -> Option<PathBuf> {
356 if let Ok(path) = std::env::var("HEDDLE_CONFIG")
357 && !path.is_empty()
358 {
359 return Some(PathBuf::from(path));
360 }
361 if let Some(home) = heddle_home_override() {
362 return Some(home.join("config.toml"));
363 }
364 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
365 && !xdg.is_empty()
366 {
367 return Some(PathBuf::from(xdg).join("heddle").join("config.toml"));
368 }
369 if let Ok(home) = std::env::var("HOME")
370 && !home.is_empty()
371 {
372 return Some(PathBuf::from(home).join(".config/heddle/config.toml"));
373 }
374 None
375 }
376
377 pub fn load(path: &Path) -> anyhow::Result<Self> {
378 let mut file = fs::File::open(path)?;
379 let mut contents = String::new();
380 file.read_to_string(&mut contents)?;
381 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
382 if let Some(value) = invalid_output_format_value(&contents) {
383 return Err(objects::error::HeddleError::ConfigInvalidValue {
384 path: resolved,
385 key: "output.format".to_string(),
386 value,
387 valid_values: vec!["'text'".to_string(), "'json'".to_string()],
388 }
389 .into());
390 }
391 toml::from_str::<Self>(&contents).map_err(|err| {
399 objects::error::HeddleError::ConfigParse {
400 path: resolved,
401 source: err,
402 }
403 .into()
404 })
405 }
406
407 pub fn load_default() -> anyhow::Result<Self> {
408 match Self::default_path() {
409 Some(path) => match Self::load(&path) {
410 Ok(config) => Ok(config),
411 Err(err) if path_missing(&err) => Ok(Self::default()),
412 Err(err) => Err(err),
413 },
414 None => Ok(Self::default()),
415 }
416 }
417
418 pub fn save_default(&self) -> anyhow::Result<PathBuf> {
419 self.stage_default()?.publish()
420 }
421
422 pub fn stage_default(&self) -> anyhow::Result<StagedUserConfig> {
423 let path = Self::default_path()
424 .ok_or_else(|| anyhow::anyhow!("unable to determine user config path"))?;
425 self.stage(&path)
426 }
427
428 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
429 self.stage(path)?.publish()?;
430 Ok(())
431 }
432
433 pub fn stage(&self, path: &Path) -> anyhow::Result<StagedUserConfig> {
434 let contents = toml::to_string_pretty(self)?;
435 let write = stage_file_atomic_secret(path, contents.as_bytes())?;
436 Ok(StagedUserConfig {
437 path: path.to_path_buf(),
438 write,
439 })
440 }
441
442 pub fn set_principal(&mut self, name: impl Into<String>, email: impl Into<String>) {
443 self.principal = Some(UserPrincipalConfig {
444 name: name.into(),
445 email: email.into(),
446 });
447 }
448
449 pub fn command_auto_capture_enabled(&self) -> anyhow::Result<bool> {
450 let mut mode = self.capture.auto;
451 match env::var("HEDDLE_AUTO_CAPTURE") {
452 Ok(value) if !value.trim().is_empty() => {
453 mode = parse_auto_capture_env("HEDDLE_AUTO_CAPTURE", &value)?;
454 }
455 Ok(_) | Err(env::VarError::NotPresent) => {}
456 Err(err @ env::VarError::NotUnicode(_)) => {
457 return Err(config_value_error(
458 "HEDDLE_AUTO_CAPTURE",
459 format!("read environment value: {err}"),
460 ));
461 }
462 }
463 Ok(matches!(mode, UserAutoCaptureMode::Command))
464 }
465
466 pub fn set_remote_tls_repo_start(path: PathBuf) {
472 let _ = REMOTE_TLS_REPO_START.set(path);
473 }
474
475 pub fn remote_tls_ca_certificate_pem(
485 &self,
486 repo_start: Option<&Path>,
487 ) -> anyhow::Result<Option<String>> {
488 let cwd;
489 let start = if let Some(path) = repo_start {
490 Some(path)
491 } else if let Some(path) = REMOTE_TLS_REPO_START.get() {
492 Some(path.as_path())
493 } else {
494 cwd = env::current_dir().ok();
495 cwd.as_deref()
496 };
497 self.remote_tls_ca_certificate_pem_from(start)
498 }
499
500 fn remote_tls_ca_certificate_pem_from(
501 &self,
502 repo_start: Option<&Path>,
503 ) -> anyhow::Result<Option<String>> {
504 let mut ca_pem = self
505 .remote
506 .tls_ca_certificate_path
507 .as_ref()
508 .map(|path| read_security_config_file("remote.tls_ca_certificate_path", path))
509 .transpose()?;
510 if ca_pem.is_none()
511 && let Some(path) = discovered_repo_tls_ca_certificate_path(repo_start)?
512 {
513 ca_pem = Some(read_security_config_file(
514 "remote.tls_ca_certificate_path",
515 &path,
516 )?);
517 }
518 match env::var("HEDDLE_REMOTE_TLS_CA_CERT") {
519 Ok(path) => {
520 ca_pem = Some(read_security_config_file(
521 "HEDDLE_REMOTE_TLS_CA_CERT",
522 &PathBuf::from(path),
523 )?);
524 }
525 Err(env::VarError::NotPresent) => {}
526 Err(err @ env::VarError::NotUnicode(_)) => {
527 return Err(security_config_error(
528 "HEDDLE_REMOTE_TLS_CA_CERT",
529 format!("read environment value: {err}"),
530 ));
531 }
532 }
533 Ok(ca_pem)
534 }
535
536 pub fn hosted_runtime_config(&self, token: Option<AuthToken>) -> anyhow::Result<ClientConfig> {
541 let mut config = token
542 .map(|token| ClientConfig::default().with_token(token))
543 .unwrap_or_default();
544
545 if self.remote.tls_enabled {
546 config = config.with_tls(false);
547 }
548 if self.remote.insecure {
549 config = config.with_allow_insecure(true);
550 }
551 if let Some(domain) = &self.remote.tls_domain_name {
552 config = config.with_tls_domain_name(domain.clone());
553 }
554 if let Some(pem) = self.remote_tls_ca_certificate_pem(None)? {
555 config = config.with_tls_ca_certificate_pem(pem);
556 }
557 if let Some(path) = &self.remote.auth_proof_key_pem_path {
558 let pem = read_security_config_file("remote.auth_proof_key_pem_path", path)?;
559 config = config.with_auth_proof_key_pem(pem);
560 }
561 if let (Some(key_id), Some(path)) = (
562 self.remote.iroh_descriptor_key_id.as_deref(),
563 self.remote.iroh_descriptor_public_key_path.as_deref(),
564 ) {
565 config = config.with_descriptor_trust(
566 key_id,
567 read_descriptor_public_key("remote.iroh_descriptor_public_key_path", path)?,
568 );
569 } else if self.remote.iroh_descriptor_key_id.is_some()
570 || self.remote.iroh_descriptor_public_key_path.is_some()
571 {
572 return Err(security_config_error(
573 "remote.iroh_descriptor_key_id/remote.iroh_descriptor_public_key_path",
574 "both descriptor trust fields are required".to_string(),
575 ));
576 }
577 if let Some(value) = self.remote.provider_global_concurrency {
578 config = config.with_provider_global_concurrency(value);
579 }
580 if let Some(value) = self.remote.provider_per_endpoint_concurrency {
581 config = config.with_provider_per_endpoint_concurrency(value);
582 }
583 if let Some(value) = self.remote.provider_max_inflight_bytes {
584 config = config.with_provider_max_inflight_bytes(value);
585 }
586 if let Some(value) = self.remote.provider_stall_timeout_secs {
587 config = config.with_provider_stall_timeout(value);
588 }
589
590 if env_bool("HEDDLE_REMOTE_TLS")? {
591 config = config.with_tls(false);
592 }
593 if env_bool("HEDDLE_REMOTE_INSECURE")? {
594 config = config.with_allow_insecure(true);
595 }
596 match env::var("HEDDLE_REMOTE_TLS_DOMAIN") {
597 Ok(domain) => config = config.with_tls_domain_name(domain),
598 Err(env::VarError::NotPresent) => {}
599 Err(err @ env::VarError::NotUnicode(_)) => {
600 return Err(security_config_error(
601 "HEDDLE_REMOTE_TLS_DOMAIN",
602 format!("read environment value: {err}"),
603 ));
604 }
605 }
606 match (
607 env::var("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID"),
608 env::var("HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY"),
609 ) {
610 (Ok(key_id), Ok(public_key)) if !key_id.is_empty() && !public_key.is_empty() => {
611 config = config.with_descriptor_trust(
612 key_id,
613 parse_descriptor_public_key(
614 "HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
615 &public_key,
616 )?,
617 );
618 }
619 (Err(env::VarError::NotPresent), Err(env::VarError::NotPresent)) => {}
620 (Ok(_), Err(env::VarError::NotPresent))
621 | (Err(env::VarError::NotPresent), Ok(_))
622 | (Ok(_), Ok(_)) => {
623 return Err(security_config_error(
624 "HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID/HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
625 "both non-empty descriptor trust values are required".to_string(),
626 ));
627 }
628 (Err(error), _) | (_, Err(error)) => {
629 return Err(security_config_error(
630 "HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID/HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
631 format!("read environment value: {error}"),
632 ));
633 }
634 }
635 if let Some(value) = env_positive::<usize>("HEDDLE_REMOTE_PROVIDER_GLOBAL_CONCURRENCY")? {
636 config = config.with_provider_global_concurrency(value);
637 }
638 if let Some(value) =
639 env_positive::<usize>("HEDDLE_REMOTE_PROVIDER_PER_ENDPOINT_CONCURRENCY")?
640 {
641 config = config.with_provider_per_endpoint_concurrency(value);
642 }
643 if let Some(value) = env_positive::<usize>("HEDDLE_REMOTE_PROVIDER_MAX_INFLIGHT_BYTES")? {
644 config = config.with_provider_max_inflight_bytes(value);
645 }
646 if let Some(value) = env_positive::<u64>("HEDDLE_REMOTE_PROVIDER_STALL_TIMEOUT_SECS")? {
647 config = config.with_provider_stall_timeout(value);
648 }
649 Ok(config)
650 }
651
652 pub fn worktree_status_options(
653 &self,
654 repo_config: Option<&repo::RepoConfig>,
655 ) -> WorktreeStatusOptions {
656 let mut mode = self
657 .worktree
658 .fsmonitor
659 .mode
660 .or_else(|| repo_config.map(|config| config.worktree.fsmonitor.mode))
661 .unwrap_or_default();
662 if let Ok(value) = std::env::var("HEDDLE_FSMONITOR")
663 && let Some(parsed) = FsMonitorMode::parse(&value)
664 {
665 mode = parsed;
666 }
667
668 WorktreeStatusOptions {
669 fsmonitor: FsMonitorSettings { mode },
670 }
671 }
672}
673
674pub fn resolve_principal(
680 repo: &Repository,
681 user_config: &UserConfig,
682) -> repo::Result<ResolvedPrincipal> {
683 if let Some(resolved) = configured_from_env() {
684 return Ok(resolved);
685 }
686 if let Some(config) = &repo.config().principal {
687 return Ok(ResolvedPrincipal::configured(
688 Principal::new(&config.name, &config.email),
689 "repository",
690 ));
691 }
692 let principal = repo.get_principal()?;
693 if principal_is_accountable(&principal) {
694 return Ok(ResolvedPrincipal::configured(principal, "git_config"));
695 }
696 Ok(finish_principal_resolution(user_config, principal))
697}
698
699pub fn resolve_principal_without_repo(user_config: &UserConfig) -> ResolvedPrincipal {
704 if let Some(resolved) = configured_from_env() {
705 return resolved;
706 }
707 finish_principal_resolution(
708 user_config,
709 Principal::new("Unknown", "unknown@example.com"),
710 )
711}
712
713fn configured_from_env() -> Option<ResolvedPrincipal> {
714 Principal::from_env().map(|principal| ResolvedPrincipal::configured(principal, "environment"))
715}
716
717fn finish_principal_resolution(user_config: &UserConfig, fallback: Principal) -> ResolvedPrincipal {
718 if let Some(config) = &user_config.principal {
719 return ResolvedPrincipal::configured(
720 Principal::new(&config.name, &config.email),
721 "user_config",
722 );
723 }
724 ResolvedPrincipal::unknown(fallback)
725}
726
727pub fn principal_source_display(source: &str) -> &str {
731 match source {
732 "user_config" => "user_config (shared global config)",
733 _ => source,
734 }
735}
736
737fn principal_is_accountable(principal: &Principal) -> bool {
738 let name = principal.name_lossy();
739 let email = principal.email_lossy();
740 let name = name.trim();
741 let email = email.trim();
742 !name.is_empty() && !email.is_empty() && !(name == "Unknown" && email == "unknown@example.com")
743}
744
745fn parse_auto_capture_env(setting: &str, value: &str) -> anyhow::Result<UserAutoCaptureMode> {
746 match value.trim().to_ascii_lowercase().as_str() {
747 "1" | "true" | "yes" | "on" | "command" | "commands" => Ok(UserAutoCaptureMode::Command),
748 "0" | "false" | "no" | "off" => Ok(UserAutoCaptureMode::Off),
749 _ => Err(config_value_error(
750 setting,
751 format!(
752 "parse auto-capture value {value:?}; expected one of off, command, true, or false"
753 ),
754 )),
755 }
756}
757
758fn invalid_output_format_value(contents: &str) -> Option<String> {
759 let value = toml::from_str::<toml::Value>(contents).ok()?;
760 let format = value
761 .get("output")
762 .and_then(|output| output.get("format"))
763 .and_then(toml::Value::as_str)?;
764 (!matches!(format, "text" | "json")).then(|| format.to_string())
765}
766
767fn read_security_config_file(setting: &str, path: &Path) -> anyhow::Result<String> {
768 fs::read_to_string(path).map_err(|err| {
769 security_config_error(
770 setting,
771 format!("read configured file {}: {err}", path.display()),
772 )
773 })
774}
775
776fn read_descriptor_public_key(setting: &str, path: &Path) -> anyhow::Result<[u8; 32]> {
777 let value = read_security_config_file(setting, path)?;
778 parse_descriptor_public_key(setting, value.trim())
779}
780
781fn parse_descriptor_public_key(setting: &str, value: &str) -> anyhow::Result<[u8; 32]> {
782 let bytes = hex::decode(value).map_err(|error| {
783 security_config_error(setting, format!("decode hex descriptor key: {error}"))
784 })?;
785 bytes.try_into().map_err(|_| {
786 security_config_error(
787 setting,
788 "descriptor key must be a 32-byte Ed25519 public key".to_string(),
789 )
790 })
791}
792
793fn env_bool(name: &str) -> anyhow::Result<bool> {
794 let value = match env::var(name) {
795 Ok(value) => value,
796 Err(env::VarError::NotPresent) => return Ok(false),
797 Err(err @ env::VarError::NotUnicode(_)) => {
798 return Err(security_config_error(
799 name,
800 format!("read environment value: {err}"),
801 ));
802 }
803 };
804 match value.trim().to_ascii_lowercase().as_str() {
805 "1" | "true" | "yes" | "on" => Ok(true),
806 "0" | "false" | "no" | "off" => Ok(false),
807 _ => Err(security_config_error(
808 name,
809 format!(
810 "parse boolean value {value:?}; expected one of 1/0, true/false, yes/no, or on/off"
811 ),
812 )),
813 }
814}
815
816fn env_positive<T>(name: &str) -> anyhow::Result<Option<T>>
817where
818 T: std::str::FromStr + PartialEq + Default,
819 T::Err: std::fmt::Display,
820{
821 let value = match env::var(name) {
822 Ok(value) => value,
823 Err(env::VarError::NotPresent) => return Ok(None),
824 Err(err @ env::VarError::NotUnicode(_)) => {
825 return Err(config_value_error(
826 name,
827 format!("read environment value: {err}"),
828 ));
829 }
830 };
831 let parsed = value.trim().parse::<T>().map_err(|error| {
832 config_value_error(name, format!("parse positive integer value: {error}"))
833 })?;
834 if parsed == T::default() {
835 return Err(config_value_error(
836 name,
837 "value must be greater than zero".to_string(),
838 ));
839 }
840 Ok(Some(parsed))
841}
842
843fn discovered_repo_tls_ca_certificate_path(
844 start: Option<&Path>,
845) -> anyhow::Result<Option<PathBuf>> {
846 let Some(start) = start else {
847 return Ok(None);
848 };
849 let Some(root) = repo::discover_heddle_root(start) else {
850 return Ok(None);
851 };
852 let config_path = root.join(".heddle/config.toml");
853 let contents = match fs::read_to_string(&config_path) {
854 Ok(contents) => contents,
855 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
856 Err(err) => {
857 return Err(security_config_error(
858 "remote.tls_ca_certificate_path",
859 format!("read repository config {}: {err}", config_path.display()),
860 ));
861 }
862 };
863 let probe = toml::from_str::<RepoRemoteProbe>(&contents).map_err(|err| {
864 security_config_error(
865 "remote.tls_ca_certificate_path",
866 format!("parse repository config {}: {err}", config_path.display()),
867 )
868 })?;
869 let Some(path) = probe
870 .remote
871 .tls_ca_certificate_path
872 .filter(|path| !path.as_os_str().is_empty())
873 else {
874 return Ok(None);
875 };
876 Ok(Some(if path.is_absolute() {
877 path
878 } else {
879 root.join(path)
880 }))
881}
882
883#[derive(Debug, Default, Deserialize)]
884struct RepoRemoteProbe {
885 #[serde(default)]
886 remote: repo::RepoRemoteConfig,
887}
888
889fn config_value_error(setting: &str, reason: String) -> anyhow::Error {
890 anyhow::anyhow!("fatal configuration error for `{setting}`: {reason}")
891}
892
893fn security_config_error(setting: &str, reason: String) -> anyhow::Error {
894 anyhow::anyhow!(
895 "fatal TLS/auth configuration error for `{setting}`: {reason}; refusing to proceed with an ambiguous security posture"
896 )
897}
898
899fn path_missing(err: &anyhow::Error) -> bool {
900 err.downcast_ref::<std::io::Error>()
901 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
902}
903
904#[cfg(test)]
905mod tests {
906 use std::{
907 ffi::OsString,
908 fs,
909 path::PathBuf,
910 sync::MutexGuard,
911 time::{SystemTime, UNIX_EPOCH},
912 };
913
914 use repo::{FsMonitorMode, RepoConfig};
915
916 use super::{
917 HarnessMode, HarnessTranscriptMode, HarnessTransport, UserAutoCaptureMode,
918 UserCaptureConfig, UserConfig, UserRemoteConfig,
919 };
920
921 static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
922 const REMOTE_ENV_KEYS: &[&str] = &[
923 "HEDDLE_REMOTE_TLS",
924 "HEDDLE_REMOTE_TLS_DOMAIN",
925 "HEDDLE_REMOTE_TLS_CA_CERT",
926 "HEDDLE_REMOTE_INSECURE",
927 "HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID",
928 "HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
929 "HEDDLE_REMOTE_PROVIDER_GLOBAL_CONCURRENCY",
930 "HEDDLE_REMOTE_PROVIDER_PER_ENDPOINT_CONCURRENCY",
931 "HEDDLE_REMOTE_PROVIDER_MAX_INFLIGHT_BYTES",
932 "HEDDLE_REMOTE_PROVIDER_STALL_TIMEOUT_SECS",
933 "HEDDLE_AUTO_CAPTURE",
934 ];
935
936 struct RemoteEnvGuard {
937 _guard: MutexGuard<'static, ()>,
938 saved: Vec<(&'static str, Option<OsString>)>,
939 }
940
941 impl RemoteEnvGuard {
942 fn clean() -> Self {
943 let guard = TEST_ENV_LOCK
944 .lock()
945 .unwrap_or_else(|poisoned| poisoned.into_inner());
946 let saved = REMOTE_ENV_KEYS
947 .iter()
948 .map(|key| (*key, std::env::var_os(key)))
949 .collect();
950 for key in REMOTE_ENV_KEYS {
951 unsafe { std::env::remove_var(key) };
952 }
953 Self {
954 _guard: guard,
955 saved,
956 }
957 }
958
959 fn set(&self, key: &str, value: impl AsRef<std::ffi::OsStr>) {
960 unsafe { std::env::set_var(key, value) };
961 }
962 }
963
964 impl Drop for RemoteEnvGuard {
965 fn drop(&mut self) {
966 for (key, value) in &self.saved {
967 unsafe {
968 if let Some(value) = value {
969 std::env::set_var(key, value);
970 } else {
971 std::env::remove_var(key);
972 }
973 }
974 }
975 }
976 }
977
978 fn unique_temp_path(prefix: &str) -> PathBuf {
979 let unique = SystemTime::now()
980 .duration_since(UNIX_EPOCH)
981 .expect("system time before unix epoch")
982 .as_nanos();
983 std::env::temp_dir().join(format!("{prefix}-{}-{unique}", std::process::id()))
984 }
985
986 #[test]
987 fn user_worktree_status_options_fall_back_to_repo_config() {
988 let mut repo = RepoConfig::default();
989 repo.worktree.fsmonitor.mode = FsMonitorMode::Watchman;
990
991 let config = UserConfig::default();
992 let options = config.worktree_status_options(Some(&repo));
993
994 assert_eq!(options.fsmonitor.mode, FsMonitorMode::Watchman);
995 }
996
997 #[test]
998 fn user_worktree_status_options_default_to_off() {
999 let config = UserConfig::default();
1000 let options = config.worktree_status_options(None);
1001
1002 assert_eq!(options.fsmonitor.mode, FsMonitorMode::Off);
1003 }
1004
1005 #[test]
1006 fn harness_config_defaults_are_magical_but_safe() {
1007 let config = UserConfig::default();
1008 assert_eq!(config.harness.mode, HarnessMode::Auto);
1009 assert_eq!(config.harness.transport, HarnessTransport::Spool);
1010 assert_eq!(config.harness.transcript, HarnessTranscriptMode::Off);
1011 assert!(config.harness.auto_infer);
1012 assert!(config.harness.harnesses.is_empty());
1013 }
1014
1015 #[test]
1016 fn command_auto_capture_defaults_off() {
1017 let _env = RemoteEnvGuard::clean();
1018
1019 let config = UserConfig::default();
1020
1021 assert!(!config.command_auto_capture_enabled().unwrap());
1022 }
1023
1024 #[test]
1025 fn command_auto_capture_reads_user_config() {
1026 let _env = RemoteEnvGuard::clean();
1027 let config = UserConfig {
1028 capture: UserCaptureConfig {
1029 auto: UserAutoCaptureMode::Command,
1030 },
1031 ..UserConfig::default()
1032 };
1033
1034 assert!(config.command_auto_capture_enabled().unwrap());
1035 }
1036
1037 #[test]
1038 fn command_auto_capture_env_overrides_user_config() {
1039 let env = RemoteEnvGuard::clean();
1040 env.set("HEDDLE_AUTO_CAPTURE", "off");
1041 let config = UserConfig {
1042 capture: UserCaptureConfig {
1043 auto: UserAutoCaptureMode::Command,
1044 },
1045 ..UserConfig::default()
1046 };
1047
1048 assert!(!config.command_auto_capture_enabled().unwrap());
1049
1050 env.set("HEDDLE_AUTO_CAPTURE", "command");
1051 assert!(
1052 UserConfig::default()
1053 .command_auto_capture_enabled()
1054 .unwrap()
1055 );
1056 }
1057
1058 #[test]
1059 fn user_config_toml_parses_capture_auto_command() {
1060 let parsed: UserConfig = toml::from_str(
1061 r#"
1062 [capture]
1063 auto = "command"
1064 "#,
1065 )
1066 .expect("capture auto config should parse");
1067
1068 assert_eq!(parsed.capture.auto, UserAutoCaptureMode::Command);
1069 }
1070
1071 #[test]
1072 fn hosted_runtime_config_absent_security_settings_uses_defaults() {
1073 let _env = RemoteEnvGuard::clean();
1074 let config = UserConfig::default()
1075 .hosted_runtime_config(None)
1076 .expect("absent optional settings should not error");
1077
1078 assert!(!config.tls_enabled);
1079 assert!(!config.tls_skip_verify);
1080 assert!(config.tls_ca_certificate_pem.is_none());
1081 assert!(config.auth_proof_key_pem.is_none());
1082 assert!(config.token.is_none());
1083 assert!(config.descriptor_key_id.is_none());
1084 assert!(config.descriptor_public_key.is_none());
1085 assert_eq!(config.provider_global_concurrency, 4);
1086 assert_eq!(config.provider_per_endpoint_concurrency, 2);
1087 assert_eq!(config.provider_max_inflight_bytes, 8 * 1024 * 1024);
1088 assert_eq!(config.provider_stall_timeout_secs, 15);
1089 }
1090
1091 #[test]
1092 fn hosted_runtime_config_loads_provider_knobs_from_user_config_and_env() {
1093 let env = RemoteEnvGuard::clean();
1094 env.set("HEDDLE_REMOTE_PROVIDER_GLOBAL_CONCURRENCY", "16");
1095 env.set("HEDDLE_REMOTE_PROVIDER_MAX_INFLIGHT_BYTES", "33554432");
1096 let user: UserConfig = toml::from_str(
1097 r#"
1098 [remote]
1099 provider_global_concurrency = 8
1100 provider_per_endpoint_concurrency = 4
1101 provider_max_inflight_bytes = 16777216
1102 provider_stall_timeout_secs = 30
1103 "#,
1104 )
1105 .unwrap();
1106
1107 let config = user.hosted_runtime_config(None).unwrap();
1108
1109 assert_eq!(config.provider_global_concurrency, 16);
1110 assert_eq!(config.provider_per_endpoint_concurrency, 4);
1111 assert_eq!(config.provider_max_inflight_bytes, 32 * 1024 * 1024);
1112 assert_eq!(config.provider_stall_timeout_secs, 30);
1113 }
1114
1115 #[test]
1116 fn hosted_runtime_config_rejects_invalid_provider_env_knob() {
1117 let env = RemoteEnvGuard::clean();
1118 env.set("HEDDLE_REMOTE_PROVIDER_STALL_TIMEOUT_SECS", "0");
1119
1120 let error = UserConfig::default()
1121 .hosted_runtime_config(None)
1122 .expect_err("zero provider timeout must be rejected");
1123
1124 assert!(
1125 error
1126 .to_string()
1127 .contains("HEDDLE_REMOTE_PROVIDER_STALL_TIMEOUT_SECS")
1128 );
1129 }
1130
1131 #[test]
1132 fn hosted_runtime_config_loads_descriptor_trust_from_environment() {
1133 let env = RemoteEnvGuard::clean();
1134 env.set("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID", "weft-current");
1135 env.set(
1136 "HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
1137 hex::encode([17; 32]),
1138 );
1139
1140 let config = UserConfig::default().hosted_runtime_config(None).unwrap();
1141
1142 assert_eq!(config.descriptor_key_id.as_deref(), Some("weft-current"));
1143 assert_eq!(config.descriptor_public_key, Some([17; 32]));
1144 }
1145
1146 #[test]
1147 fn hosted_runtime_config_rejects_key_only_environment_descriptor_trust() {
1148 let env = RemoteEnvGuard::clean();
1149 env.set("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID", "weft-current");
1150
1151 let error = UserConfig::default()
1152 .hosted_runtime_config(None)
1153 .expect_err("partial descriptor trust must fail closed");
1154
1155 assert!(
1156 error
1157 .to_string()
1158 .contains("both non-empty descriptor trust")
1159 );
1160 assert!(error.to_string().contains("ambiguous security posture"));
1161 }
1162
1163 #[test]
1164 fn hosted_runtime_config_rejects_public_key_only_environment_descriptor_trust() {
1165 let env = RemoteEnvGuard::clean();
1166 env.set(
1167 "HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
1168 hex::encode([17; 32]),
1169 );
1170
1171 let error = UserConfig::default()
1172 .hosted_runtime_config(None)
1173 .expect_err("partial descriptor trust must fail closed");
1174
1175 assert!(
1176 error
1177 .to_string()
1178 .contains("both non-empty descriptor trust")
1179 );
1180 assert!(error.to_string().contains("ambiguous security posture"));
1181 }
1182
1183 #[test]
1184 fn hosted_runtime_config_rejects_key_only_file_descriptor_trust() {
1185 let _env = RemoteEnvGuard::clean();
1186 let user = UserConfig {
1187 remote: UserRemoteConfig {
1188 iroh_descriptor_key_id: Some("weft-current".to_string()),
1189 ..UserRemoteConfig::default()
1190 },
1191 ..UserConfig::default()
1192 };
1193
1194 let error = user
1195 .hosted_runtime_config(None)
1196 .expect_err("partial descriptor trust must fail closed");
1197
1198 assert!(error.to_string().contains("both descriptor trust fields"));
1199 assert!(error.to_string().contains("ambiguous security posture"));
1200 }
1201
1202 #[test]
1203 fn hosted_runtime_config_rejects_public_key_only_file_descriptor_trust() {
1204 let _env = RemoteEnvGuard::clean();
1205 let dir = unique_temp_path("heddle-user-config-partial-descriptor");
1206 fs::create_dir_all(&dir).expect("create temp dir");
1207 let public_key_path = dir.join("descriptor-public-key");
1208 fs::write(&public_key_path, hex::encode([17; 32])).expect("write descriptor public key");
1209 let user = UserConfig {
1210 remote: UserRemoteConfig {
1211 iroh_descriptor_public_key_path: Some(public_key_path),
1212 ..UserRemoteConfig::default()
1213 },
1214 ..UserConfig::default()
1215 };
1216
1217 let error = user
1218 .hosted_runtime_config(None)
1219 .expect_err("partial descriptor trust must fail closed");
1220
1221 assert!(error.to_string().contains("both descriptor trust fields"));
1222 assert!(error.to_string().contains("ambiguous security posture"));
1223 fs::remove_dir_all(dir).expect("remove temp dir");
1224 }
1225
1226 #[test]
1227 fn hosted_runtime_config_valid_security_files_are_applied() {
1228 let _env = RemoteEnvGuard::clean();
1229 let dir = unique_temp_path("heddle-user-config-valid-security");
1230 fs::create_dir_all(&dir).expect("create temp dir");
1231 let ca_path = dir.join("ca.pem");
1232 fs::write(&ca_path, "test ca pem").expect("write ca pem");
1233 let user = UserConfig {
1234 remote: UserRemoteConfig {
1235 tls_ca_certificate_path: Some(ca_path),
1236 ..UserRemoteConfig::default()
1237 },
1238 ..UserConfig::default()
1239 };
1240
1241 let config = user
1242 .hosted_runtime_config(None)
1243 .expect("valid TLS/auth files should load");
1244
1245 assert!(config.tls_enabled);
1246 assert_eq!(
1247 config.tls_ca_certificate_pem.as_deref(),
1248 Some("test ca pem")
1249 );
1250
1251 fs::remove_dir_all(dir).expect("remove temp dir");
1252 }
1253
1254 #[test]
1255 fn hosted_runtime_config_missing_tls_ca_path_fails_closed() {
1256 let _env = RemoteEnvGuard::clean();
1257 let missing = unique_temp_path("heddle-user-config-missing-ca").join("ca.pem");
1258 let user = UserConfig {
1259 remote: UserRemoteConfig {
1260 tls_ca_certificate_path: Some(missing),
1261 ..UserRemoteConfig::default()
1262 },
1263 ..UserConfig::default()
1264 };
1265
1266 let err = user
1267 .hosted_runtime_config(None)
1268 .expect_err("missing configured CA path must fail closed");
1269 let message = err.to_string();
1270
1271 assert!(message.contains("fatal TLS/auth configuration error"));
1272 assert!(message.contains("remote.tls_ca_certificate_path"));
1273 }
1274
1275 #[test]
1276 fn hosted_runtime_config_missing_env_tls_ca_path_fails_closed() {
1277 let env = RemoteEnvGuard::clean();
1278 let missing = unique_temp_path("heddle-user-config-missing-env-ca").join("ca.pem");
1279 env.set("HEDDLE_REMOTE_TLS_CA_CERT", missing);
1280
1281 let err = UserConfig::default()
1282 .hosted_runtime_config(None)
1283 .expect_err("missing env CA path must fail closed");
1284 let message = err.to_string();
1285
1286 assert!(message.contains("fatal TLS/auth configuration error"));
1287 assert!(message.contains("HEDDLE_REMOTE_TLS_CA_CERT"));
1288 }
1289
1290 #[test]
1291 fn hosted_runtime_config_invalid_env_tls_value_fails_closed() {
1292 let env = RemoteEnvGuard::clean();
1293 env.set("HEDDLE_REMOTE_TLS", "enabled");
1294
1295 let err = UserConfig::default()
1296 .hosted_runtime_config(None)
1297 .expect_err("invalid TLS env value must fail closed");
1298 let message = err.to_string();
1299
1300 assert!(message.contains("fatal TLS/auth configuration error"));
1301 assert!(message.contains("HEDDLE_REMOTE_TLS"));
1302 }
1303
1304 fn write_discoverable_repo(root: &std::path::Path, remote_toml: &str) {
1305 fs::create_dir_all(root.join(".heddle")).expect("create .heddle");
1306 fs::write(root.join(".heddle/HEAD"), "ref: refs/heddle/heads/main\n").expect("write HEAD");
1307 fs::write(
1308 root.join(".heddle/config.toml"),
1309 format!("[repository]\nversion = 4\nsource_authority = \"native\"\n{remote_toml}"),
1310 )
1311 .expect("write repo config");
1312 }
1313
1314 #[test]
1315 fn remote_tls_ca_honours_repo_config_when_user_config_is_unset() {
1316 let _env = RemoteEnvGuard::clean();
1317 let root = unique_temp_path("heddle-repo-tls-ca");
1318 let ca_path = root.join("ca.pem");
1319 fs::create_dir_all(&root).expect("create repo root");
1320 fs::write(&ca_path, "repo ca pem").expect("write repo CA");
1321 write_discoverable_repo(
1322 &root,
1323 &format!(
1324 "\n[remote]\ntls_ca_certificate_path = \"{}\"\n",
1325 ca_path.display()
1326 ),
1327 );
1328
1329 let pem = UserConfig::default()
1330 .remote_tls_ca_certificate_pem_from(Some(&root))
1331 .expect("repo CA should load");
1332 assert_eq!(pem.as_deref(), Some("repo ca pem"));
1333 fs::remove_dir_all(root).expect("remove temp dir");
1334 }
1335
1336 #[test]
1337 fn remote_tls_ca_user_config_overrides_repo_config() {
1338 let _env = RemoteEnvGuard::clean();
1339 let root = unique_temp_path("heddle-repo-tls-ca-override");
1340 fs::create_dir_all(&root).expect("create repo root");
1341 let repo_ca = root.join("repo-ca.pem");
1342 let user_ca = root.join("user-ca.pem");
1343 fs::write(&repo_ca, "repo ca pem").expect("write repo CA");
1344 fs::write(&user_ca, "user ca pem").expect("write user CA");
1345 write_discoverable_repo(
1346 &root,
1347 &format!(
1348 "\n[remote]\ntls_ca_certificate_path = \"{}\"\n",
1349 repo_ca.display()
1350 ),
1351 );
1352
1353 let user = UserConfig {
1354 remote: UserRemoteConfig {
1355 tls_ca_certificate_path: Some(user_ca),
1356 ..UserRemoteConfig::default()
1357 },
1358 ..UserConfig::default()
1359 };
1360 let pem = user
1361 .remote_tls_ca_certificate_pem_from(Some(&root))
1362 .expect("user CA should win");
1363 assert_eq!(pem.as_deref(), Some("user ca pem"));
1364 fs::remove_dir_all(root).expect("remove temp dir");
1365 }
1366
1367 #[test]
1368 fn remote_tls_ca_selected_start_is_not_another_repo() {
1369 let _env = RemoteEnvGuard::clean();
1370 let parent = unique_temp_path("heddle-repo-tls-ca-dash-c");
1371 let cwd_root = parent.join("cwd-repo");
1372 let selected_root = parent.join("selected-repo");
1373 fs::create_dir_all(&cwd_root).expect("create cwd repo");
1374 fs::create_dir_all(&selected_root).expect("create selected repo");
1375 let cwd_ca = cwd_root.join("ca.pem");
1376 let selected_ca = selected_root.join("ca.pem");
1377 fs::write(&cwd_ca, "cwd ca pem").expect("write cwd CA");
1378 fs::write(&selected_ca, "selected ca pem").expect("write selected CA");
1379 write_discoverable_repo(
1380 &cwd_root,
1381 &format!(
1382 "\n[remote]\ntls_ca_certificate_path = \"{}\"\n",
1383 cwd_ca.display()
1384 ),
1385 );
1386 write_discoverable_repo(
1387 &selected_root,
1388 &format!(
1389 "\n[remote]\ntls_ca_certificate_path = \"{}\"\n",
1390 selected_ca.display()
1391 ),
1392 );
1393
1394 let selected = UserConfig::default()
1395 .remote_tls_ca_certificate_pem(Some(&selected_root))
1396 .expect("selected repo CA should load");
1397 let other = UserConfig::default()
1398 .remote_tls_ca_certificate_pem(Some(&cwd_root))
1399 .expect("cwd repo CA should load");
1400 assert_eq!(selected.as_deref(), Some("selected ca pem"));
1401 assert_eq!(other.as_deref(), Some("cwd ca pem"));
1402 fs::remove_dir_all(parent).expect("remove temp dir");
1403 }
1404
1405 #[test]
1406 fn remote_tls_ca_unknown_repo_remote_key_fails_closed() {
1407 let _env = RemoteEnvGuard::clean();
1408 let root = unique_temp_path("heddle-repo-tls-ca-unknown");
1409 fs::create_dir_all(&root).expect("create repo root");
1410 write_discoverable_repo(&root, "\n[remote]\ntls_insecure = true\n");
1411
1412 let err = UserConfig::default()
1413 .remote_tls_ca_certificate_pem_from(Some(&root))
1414 .expect_err("unknown repo remote keys must fail closed");
1415 let message = err.to_string();
1416 assert!(message.contains("fatal TLS/auth configuration error"));
1417 assert!(
1418 message.contains("unknown field") || message.contains("tls_insecure"),
1419 "unknown remote knob must be named: {message}"
1420 );
1421 fs::remove_dir_all(root).expect("remove temp dir");
1422 }
1423}