Skip to main content

cli_shared/config/
user_config.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
23/// CLI-selected repository start (`-C` / `--repo`, else startup cwd).
24/// Recorded once so later `UserConfig::load_default()` calls still discover
25/// TLS CA from that checkout instead of a later process cwd.
26static 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/// A principal together with the configuration surface that selected it.
58#[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/// User-config default for thread workspace mode. Same vocabulary
148/// as [`crate::config::repo::ThreadMode`] and the `--workspace` flag,
149/// so a user setting `top_level_default = "materialized"` reads
150/// uniformly across the CLI surface and the thread record on disk.
151#[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    /// Allow cleartext connections to non-loopback hosts without TLS.
212    /// Prefer enabling TLS; this is an explicit opt-in for lab/VPN testing.
213    #[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        // Route TOML parse failures through `HeddleError::ConfigParse` so
392        // the CLI error envelope (see `print_error_with_hint`) can
393        // classify them and render the *actual* source file in the
394        // recovery advice — not a hard-coded `.heddle/config.toml`
395        // (Codex R3 cid 3313132711 on #271). The path is canonicalized
396        // so the rendered hint is copy/paste-safe even when the caller
397        // passed a relative or env-derived path.
398        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    /// Record the CLI-selected repository start used for TLS CA discovery.
467    ///
468    /// Startup sets this from `-C` / `--repo` (or the process cwd) before
469    /// commands open a repository, so later hosted config assembly honors
470    /// the same checkout.
471    pub fn set_remote_tls_repo_start(path: PathBuf) {
472        let _ = REMOTE_TLS_REPO_START.set(path);
473    }
474
475    /// Resolve the shared custom CA bundle used by hosted and Git transports.
476    ///
477    /// `repo_start` is the CLI-selected repository path (`-C` / `--repo`).
478    /// When omitted, uses the start recorded at process startup, then the
479    /// process cwd.
480    ///
481    /// Precedence, highest first: `HEDDLE_REMOTE_TLS_CA_CERT`, user-config
482    /// `[remote] tls_ca_certificate_path`, then the same key in repository
483    /// `.heddle/config.toml` discovered from that start path.
484    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    /// Build the validated TLS/auth client config. The bearer token is supplied
537    /// by the caller (resolved through the single `HEDDLE_CREDENTIAL` → keystore
538    /// precedence in the client crate); this method no longer reads any token
539    /// from env or user config.
540    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
674/// Resolve capture attribution once for init, status, capture, and other
675/// identity-bearing commands.
676///
677/// Precedence is environment, repository config, Git config (including a
678/// shared parent checkout), user config, then the built-in Unknown principal.
679pub 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
699/// Resolve capture attribution when no repository is open.
700///
701/// Precedence is environment, then user config, then the built-in Unknown
702/// principal. Repository and Git-config sources are unavailable without a repo.
703pub 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
727/// Human-facing source label. User config is called out as global because it
728/// is shared across repositories unless `HEDDLE_HOME` or `HEDDLE_CONFIG`
729/// isolates it.
730pub 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}