Skip to main content

mkit_cli/commands/
config_cmd.rs

1//! `mkit config` — show or set values.
2//!
3//! Most keys live in the per-repo `<repo>/.mkit/config`. Security-
4//! sensitive keys (see [`config::REPO_FORBIDDEN_KEYS`]) live in the
5//! user-scoped `$XDG_CONFIG_HOME/mkit/config` and are written there
6//! when set via this command. Unknown keys are rejected.
7
8use std::borrow::Cow;
9use std::io::Write;
10
11use clap::{Parser, ValueEnum};
12
13use crate::clap_shim;
14use crate::config::{self, Config, REPO_FORBIDDEN_KEYS};
15use crate::exit;
16use crate::format;
17
18#[derive(Debug, Clone, Copy, ValueEnum)]
19enum ConfigFormat {
20    Default,
21    Json,
22}
23
24#[derive(Debug, Parser)]
25#[command(name = "mkit config", about = "Show or set configuration values.")]
26struct ConfigOpts {
27    /// Output format for the show forms.
28    #[arg(long, value_enum, default_value = "default")]
29    format: ConfigFormat,
30    /// Remove `<KEY>` instead of showing or setting it. Deletes from
31    /// whichever scope a `set` of that key would use — the repo layer
32    /// for a repo-safe key, the user-scoped layer for a
33    /// `REPO_FORBIDDEN_KEYS` key — unless overridden by `--local` /
34    /// `--global`. Takes no positional arguments.
35    #[arg(long, value_name = "KEY")]
36    unset: Option<String>,
37    /// Force the repo-scoped layer (`<repo>/.mkit/config`) for `--unset`
38    /// or a `<key> <value>` set. Refused for a `REPO_FORBIDDEN_KEYS` key
39    /// — those must never be storable in a clone-traveling repo config.
40    #[arg(long, conflicts_with = "global")]
41    local: bool,
42    /// Force the user-scoped layer (`$XDG_CONFIG_HOME/mkit/config`) for
43    /// `--unset` or a `<key> <value>` set, even for a key that would
44    /// otherwise be repo-safe.
45    #[arg(long, conflicts_with = "local")]
46    global: bool,
47    /// Optional `<key>` to show, or `<key> <value>` pair to set.
48    args: Vec<String>,
49}
50
51#[must_use]
52pub fn run(args: &[String]) -> u8 {
53    let opts = match clap_shim::parse::<ConfigOpts>("mkit config", args) {
54        Ok(o) => o,
55        Err(code) => return code,
56    };
57    let cwd = match std::env::current_dir() {
58        Ok(p) => p,
59        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
60    };
61    let layout = match super::resolve_layout(&cwd) {
62        Ok(layout) => layout,
63        Err(code) => return code,
64    };
65    // Read both layers: the merged view drives `show`, but a write must
66    // persist ONLY the repo layer — serializing the merged config would
67    // copy user-scoped values (e.g. a private `user.email`) into
68    // `.mkit/config`, which travels with clones.
69    let layered = match config::read_layered(&layout) {
70        Ok(l) => l,
71        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
72    };
73    let json = matches!(opts.format, ConfigFormat::Json);
74
75    if let Some(raw_key) = opts.unset.as_deref() {
76        if !opts.args.is_empty() {
77            return super::usage_error("mkit config --unset takes no positional arguments");
78        }
79        return run_unset(&layout, &layered, raw_key, opts.local, opts.global);
80    }
81
82    match opts.args.len() {
83        0 => return show_all(&layered.merged, json),
84        1 => {
85            return show_one(
86                &layered.merged,
87                &config::normalize_config_key(&opts.args[0]),
88                json,
89            );
90        }
91        2 => {}
92        _ => {
93            return super::usage_error(&format!(
94                "too many arguments: expected 0, 1, or 2 positional args, got {}",
95                opts.args.len()
96            ));
97        }
98    }
99    // Git treats config section + variable names case-insensitively
100    // (`User.Name` == `user.name`), but subsection names (`remote.<name>`,
101    // `branch.<branch>`) are case-sensitive. Normalize before every
102    // downstream check — crucially BEFORE `REPO_FORBIDDEN_KEYS`, so a
103    // case-variant like `User.Identity` can never bypass the spoof guard
104    // and land in the repo layer.
105    let key_normalized = config::normalize_config_key(&opts.args[0]);
106    let key = key_normalized.as_str();
107    let value = opts.args[1].as_str();
108    if let Err(e) = config::validate_value(value) {
109        return emit_err(&format!("invalid value: {e}"), exit::CONFIG_ERROR);
110    }
111    let normalized_value = if key == "user.identity" {
112        match config::expand_user_identity(value) {
113            Ok(v) => v,
114            Err(e) => return emit_err(&format!("{key}: {e}"), exit::CONFIG_ERROR),
115        }
116    } else {
117        value.to_owned()
118    };
119    // Path-traversal validation for any key whose value is a filesystem
120    // path. Catches `..` even on the user-scoped path.
121    if is_path_key(key)
122        && let Err(e) = config::validate_key_path(&normalized_value)
123    {
124        return emit_err(&format!("{e}"), exit::CONFIG_ERROR);
125    }
126    let forbidden = REPO_FORBIDDEN_KEYS.contains(&key);
127    if opts.local && forbidden {
128        return emit_err(
129            &format!(
130                "config key `{key}` cannot be stored in the repo (--local); it is user-scoped only"
131            ),
132            exit::CONFIG_ERROR,
133        );
134    }
135    // `--global` forces the user-scoped layer even for an otherwise
136    // repo-safe key; a bare `forbidden` key always goes there regardless
137    // of flags (that's the whole point of `REPO_FORBIDDEN_KEYS`); `--local`
138    // is only meaningful (and already validated above) for repo-safe keys,
139    // where it's a no-op since that's the default.
140    if forbidden || opts.global {
141        return write_user_scoped(key, &normalized_value);
142    }
143    // Apply to the repo layer only and persist that — never the merged
144    // config — so user-scoped values are not materialized into the repo
145    // file (see the scope note above).
146    let mut repo_cfg = layered.repo;
147    if let Err(code) = apply(&mut repo_cfg, key, &normalized_value) {
148        return code;
149    }
150    match config::write(&layout, &repo_cfg) {
151        Ok(()) => exit::OK,
152        Err(e) => emit_err(&format!("write config: {e}"), exit::CANTCREAT),
153    }
154}
155
156/// `mkit config --unset <key>` — delete `<key>` from the scope a `set`
157/// of it would use (or the scope forced by `--local`/`--global`).
158/// Idempotent: unsetting an already-absent key is a silent success,
159/// like `rm -f`, not an error — only an unknown key name is rejected.
160fn run_unset(
161    layout: &mkit_core::layout::RepoLayout,
162    layered: &config::LayeredConfig,
163    raw_key: &str,
164    local: bool,
165    global: bool,
166) -> u8 {
167    let key_normalized = config::normalize_config_key(raw_key);
168    let key = key_normalized.as_str();
169    if lookup(&Config::default(), key).is_none() {
170        return emit_err(&format!("unknown config key: {key}"), exit::CONFIG_ERROR);
171    }
172    let forbidden = REPO_FORBIDDEN_KEYS.contains(&key);
173    if local && forbidden {
174        return emit_err(
175            &format!(
176                "config key `{key}` cannot be unset from the repo (--local); it is user-scoped only"
177            ),
178            exit::CONFIG_ERROR,
179        );
180    }
181    if forbidden || global {
182        return match config::remove_user_kv(key) {
183            Ok(removed) => {
184                if removed {
185                    let mut stderr = std::io::stderr().lock();
186                    let _ = writeln!(
187                        stderr,
188                        "removed `{key}` from user-scoped config at {}",
189                        config::user_config_path().display()
190                    );
191                }
192                exit::OK
193            }
194            Err(e) => emit_err(
195                &format!(
196                    "remove user config at {}: {e}",
197                    config::user_config_path().display()
198                ),
199                exit::CANTCREAT,
200            ),
201        };
202    }
203    let mut repo_cfg = layered.repo.clone();
204    match unset_repo_key(&mut repo_cfg, key) {
205        Ok(_removed) => {}
206        Err(code) => return code,
207    }
208    match config::write(layout, &repo_cfg) {
209        Ok(()) => exit::OK,
210        Err(e) => emit_err(&format!("write config: {e}"), exit::CANTCREAT),
211    }
212}
213
214/// Clear a repo-safe key from the in-memory `Config`, mirroring
215/// [`apply`]'s key match but removing instead of setting. Only
216/// repo-safe keys are reachable here — [`run_unset`] routes
217/// `REPO_FORBIDDEN_KEYS` keys to the user-scoped removal path before
218/// this is called. Returns whether the key had a value to remove
219/// (informational only — [`run_unset`] treats both outcomes as
220/// success).
221fn unset_repo_key(cfg: &mut Config, key: &str) -> Result<bool, u8> {
222    fn take_nonempty(field: &mut String) -> bool {
223        if field.is_empty() {
224            false
225        } else {
226            field.clear();
227            true
228        }
229    }
230    match key {
231        "user.name" => Ok(take_nonempty(&mut cfg.user_name)),
232        "user.email" => Ok(take_nonempty(&mut cfg.user_email)),
233        "default_branch" => Ok(take_nonempty(&mut cfg.default_branch)),
234        "durability.objects" => Ok(take_nonempty(&mut cfg.durability_objects)),
235        "remote_endpoint" => Ok(take_nonempty(&mut cfg.remote_endpoint)),
236        "remote_bucket" => Ok(take_nonempty(&mut cfg.remote_bucket)),
237        "remote_type" => Ok(take_nonempty(&mut cfg.remote_type)),
238        "transport_auth" => Ok(take_nonempty(&mut cfg.transport_auth)),
239        k if config::is_core_section(k) => match config::core_allowed_suffix(k) {
240            Some(suffix) => Ok(cfg.core.remove(&suffix).is_some()),
241            None => Err(emit_err(
242                &format!("unknown config key: {key}"),
243                exit::CONFIG_ERROR,
244            )),
245        },
246        _ => Err(emit_err(
247            &format!("unknown config key: {key}"),
248            exit::CONFIG_ERROR,
249        )),
250    }
251}
252
253fn is_path_key(key: &str) -> bool {
254    matches!(
255        key,
256        "signing_key"
257            | "ssh.user_known_hosts_file"
258            | "ssh.identity_file"
259            | "attest.external_signer_path"
260            | "attest.secp256k1_key_path"
261            | "attest.p256_key_path"
262    )
263}
264
265fn write_user_scoped(key: &str, value: &str) -> u8 {
266    match config::write_user_kv(key, value) {
267        Ok(()) => {
268            let mut stderr = std::io::stderr().lock();
269            let _ = writeln!(
270                stderr,
271                "wrote `{key}` to user-scoped config at {}",
272                config::user_config_path().display()
273            );
274            exit::OK
275        }
276        Err(e) => emit_err(
277            &format!(
278                "write user config at {}: {e}",
279                config::user_config_path().display()
280            ),
281            exit::CANTCREAT,
282        ),
283    }
284}
285
286/// Apply a key/value to the in-memory `Config`. Only repo-safe keys
287/// are reachable here — security-sensitive keys (including
288/// `user.identity`) are intercepted by [`run`] via `REPO_FORBIDDEN_KEYS`
289/// and routed to user-scoped storage before this is called.
290fn apply(cfg: &mut Config, key: &str, value: &str) -> Result<(), u8> {
291    match key {
292        // Git-compatibility aliases. Accepted and round-tripped, but
293        // **non-authoritative**: they never feed the signed commit author
294        // (that is `user.identity` / the signing key), so they are
295        // repo-safe and not in `REPO_FORBIDDEN_KEYS`.
296        "user.name" => value.clone_into(&mut cfg.user_name),
297        "user.email" => value.clone_into(&mut cfg.user_email),
298        "default_branch" => value.clone_into(&mut cfg.default_branch),
299        // SPEC-OBJECTS §10.1 durability escape hatch. Validated at the
300        // set boundary (unlike the lenient config-load fallback) so a
301        // typo can't silently leave the user on the batched default when
302        // they asked for the strict per-object schedule.
303        "durability.objects" => match value.trim().to_ascii_lowercase().as_str() {
304            "" | "batch" | "per-object" | "per_object" => {
305                value.clone_into(&mut cfg.durability_objects);
306            }
307            _ => {
308                return Err(emit_err(
309                    &format!(
310                        "invalid value for durability.objects: `{value}` (expected `batch` or `per-object`)"
311                    ),
312                    exit::CONFIG_ERROR,
313                ));
314            }
315        },
316        "remote_endpoint" => value.clone_into(&mut cfg.remote_endpoint),
317        "remote_bucket" => value.clone_into(&mut cfg.remote_bucket),
318        "remote_type" => value.clone_into(&mut cfg.remote_type),
319        // Write-auth mode for `mkit+https://`/`mkit+http://` remotes — see
320        // `Config::transport_auth`'s doc comment. Validated here (unlike
321        // the lenient config-load fallback in `config::apply_kv`, which
322        // tolerates unknown values for forward-compat with hand-edited
323        // files) so a typo doesn't silently leave `mkit push` on
324        // bearer-only auth when the user asked for signed envelopes.
325        "transport_auth" => match value.trim().to_ascii_lowercase().as_str() {
326            "" | "bearer" | "envelope" => value.clone_into(&mut cfg.transport_auth),
327            _ => {
328                return Err(emit_err(
329                    &format!(
330                        "invalid value for transport_auth: `{value}` (expected `bearer` or `envelope`)"
331                    ),
332                    exit::CONFIG_ERROR,
333                ));
334            }
335        },
336        "author_mid" => {
337            return Err(emit_err(
338                "config key `author_mid` has been removed; use `user.identity` (mid:<N>)",
339                exit::CONFIG_ERROR,
340            ));
341        }
342        // Inert git-compat `core.*` keys (section matched case-insensitively):
343        // store the allowlisted ones, and refuse the dangerous ones (they
344        // would change what mkit executes if honored). Anything else under
345        // `core.` is an unknown key.
346        k if config::is_core_section(k) => {
347            let name = k
348                .split_once('.')
349                .map_or("", |(_, n)| n)
350                .to_ascii_lowercase();
351            if let Some(suffix) = config::core_allowed_suffix(k) {
352                cfg.core.insert(suffix, value.to_string());
353            } else if config::CORE_DENIED_KEYS.contains(&name.as_str()) {
354                return Err(emit_err(
355                    &format!(
356                        "config key `{key}` is not honored by mkit and is rejected for safety"
357                    ),
358                    exit::CONFIG_ERROR,
359                ));
360            } else {
361                return Err(emit_err(
362                    &format!("unknown config key: {key}"),
363                    exit::CONFIG_ERROR,
364                ));
365            }
366        }
367        _ => {
368            return Err(emit_err(
369                &format!("unknown config key: {key}"),
370                exit::CONFIG_ERROR,
371            ));
372        }
373    }
374    Ok(())
375}
376
377/// Stable schema for the JSON form: every key the CLI knows about,
378/// paired with its value. Keys are emitted in alphabetical order so
379/// the output is deterministic and easy to snapshot-test.
380const CONFIG_KEYS: &[&str] = &[
381    "attest.default_algorithm",
382    "attest.external_signer_args",
383    "attest.external_signer_path",
384    "attest.external_signer_timeout_secs",
385    "attest.p256_key_path",
386    "attest.secp256k1_key_path",
387    "attest.signer",
388    "default_branch",
389    "durability.objects",
390    "key.backend",
391    "key.default_ref",
392    "key.ed25519_ref",
393    "key.p256_ref",
394    "key.secp256k1_ref",
395    "remote_bucket",
396    "remote_endpoint",
397    "remote_type",
398    "signer",
399    "signing_key",
400    "ssh.identity_file",
401    "ssh.strict_host_key_checking",
402    "ssh.user_known_hosts_file",
403    "transport_auth",
404    "trusted_remote_endpoint",
405    "user.email",
406    "user.identity",
407    "user.name",
408];
409
410fn lookup<'a>(cfg: &'a Config, key: &str) -> Option<Cow<'a, str>> {
411    match key {
412        "user.identity" => Some(Cow::Borrowed(&cfg.user_identity)),
413        "user.name" => Some(Cow::Borrowed(&cfg.user_name)),
414        "user.email" => Some(Cow::Borrowed(&cfg.user_email)),
415        "trusted_remote_endpoint" => Some(Cow::Borrowed(&cfg.trusted_remote_endpoint)),
416        "signing_key" => Some(Cow::Borrowed(&cfg.signing_key)),
417        "default_branch" => Some(Cow::Borrowed(&cfg.default_branch)),
418        "durability.objects" => Some(Cow::Borrowed(&cfg.durability_objects)),
419        "remote_endpoint" => Some(Cow::Borrowed(&cfg.remote_endpoint)),
420        "remote_bucket" => Some(Cow::Borrowed(&cfg.remote_bucket)),
421        "remote_type" => Some(Cow::Borrowed(&cfg.remote_type)),
422        "transport_auth" => Some(Cow::Borrowed(&cfg.transport_auth)),
423        "ssh.strict_host_key_checking" => Some(Cow::Borrowed(&cfg.ssh_strict_host_key_checking)),
424        "ssh.user_known_hosts_file" => Some(Cow::Borrowed(&cfg.ssh_user_known_hosts_file)),
425        "ssh.identity_file" => Some(Cow::Borrowed(&cfg.ssh_identity_file)),
426        "signer" => Some(Cow::Borrowed(&cfg.signer)),
427        "key.backend" => Some(Cow::Borrowed(cfg.key.backend_or_fallback())),
428        "key.default_ref" => Some(Cow::Borrowed(cfg.key.default_ref_or_fallback())),
429        "key.ed25519_ref" => Some(Cow::Borrowed(cfg.key.ed25519_ref_or_fallback())),
430        "key.secp256k1_ref" => Some(Cow::Borrowed(cfg.key.secp256k1_ref_or_fallback())),
431        "key.p256_ref" => Some(Cow::Borrowed(cfg.key.p256_ref_or_fallback())),
432        "attest.default_algorithm" => {
433            Some(Cow::Borrowed(cfg.attest.default_algorithm_or_fallback()))
434        }
435        "attest.external_signer_args" => {
436            Some(Cow::Owned(cfg.attest.external_signer_args.join("|")))
437        }
438        "attest.external_signer_path" => Some(Cow::Borrowed(&cfg.attest.external_signer_path)),
439        "attest.external_signer_timeout_secs" => Some(Cow::Owned(
440            cfg.attest
441                .external_signer_timeout_secs
442                .map_or_else(String::new, |s| s.to_string()),
443        )),
444        "attest.secp256k1_key_path" => {
445            Some(Cow::Borrowed(cfg.attest.secp256k1_key_path_or_default()))
446        }
447        "attest.p256_key_path" => Some(Cow::Borrowed(cfg.attest.p256_key_path_or_default())),
448        "attest.signer" => Some(Cow::Borrowed(cfg.attest.signer_or_fallback())),
449        // Inert git-compat `core.*` keys (section matched case-insensitively):
450        // an allowlisted key returns its stored value (empty if unset, like
451        // the other keys); anything else under `core.` is unknown.
452        k if config::is_core_section(k) => config::core_allowed_suffix(k).map(|suffix| {
453            cfg.core
454                .get(&suffix)
455                .map_or(Cow::Borrowed(""), |v| Cow::Owned(v.clone()))
456        }),
457        _ => None,
458    }
459}
460
461fn show_all(cfg: &Config, json: bool) -> u8 {
462    let mut stdout = std::io::stdout().lock();
463    if json {
464        // Flat object with every known key. Unset values render as
465        // empty strings, matching the default-mode behaviour.
466        let _ = stdout.write_all(b"{");
467        for (i, key) in CONFIG_KEYS.iter().enumerate() {
468            if i > 0 {
469                let _ = stdout.write_all(b",");
470            }
471            let v = lookup(cfg, key).unwrap_or(Cow::Borrowed(""));
472            let _ = write!(
473                stdout,
474                "\"{}\":\"{}\"",
475                format::json_escape(key),
476                format::json_escape(&v)
477            );
478        }
479        // Dynamic, set-only `core.*` git-compat keys.
480        for (k, v) in &cfg.core {
481            let _ = write!(
482                stdout,
483                ",\"core.{}\":\"{}\"",
484                format::json_escape(k),
485                format::json_escape(v)
486            );
487        }
488        let _ = stdout.write_all(b"}\n");
489        return exit::OK;
490    }
491    for key in CONFIG_KEYS {
492        let v = lookup(cfg, key).unwrap_or(Cow::Borrowed(""));
493        let _ = writeln!(stdout, "{key} = {v}");
494    }
495    for (k, v) in &cfg.core {
496        let _ = writeln!(stdout, "core.{k} = {v}");
497    }
498    exit::OK
499}
500
501fn show_one(cfg: &Config, key: &str, json: bool) -> u8 {
502    let Some(v) = lookup(cfg, key) else {
503        return emit_err(&format!("unknown config key: {key}"), exit::CONFIG_ERROR);
504    };
505    let mut stdout = std::io::stdout().lock();
506    if json {
507        let _ = writeln!(
508            stdout,
509            "{{\"{}\":\"{}\"}}",
510            format::json_escape(key),
511            format::json_escape(&v)
512        );
513    } else {
514        let _ = writeln!(stdout, "{v}");
515    }
516    exit::OK
517}
518
519use super::error as emit_err;