1use std::borrow::Cow;
9use std::io::Write;
10
11use clap::{Parser, ValueEnum};
12
13use crate::clap_shim;
14use crate::config::{self, Config};
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 #[arg(long, value_enum, default_value = "default")]
29 format: ConfigFormat,
30 #[arg(long, value_name = "KEY")]
36 unset: Option<String>,
37 #[arg(long, conflicts_with = "global")]
41 local: bool,
42 #[arg(long, conflicts_with = "local")]
46 global: bool,
47 args: Vec<String>,
49}
50
51#[must_use]
52#[allow(clippy::too_many_lines)] pub fn run(args: &[String]) -> u8 {
54 let opts = match clap_shim::parse::<ConfigOpts>("mkit config", args) {
55 Ok(o) => o,
56 Err(code) => return code,
57 };
58 let cwd = match std::env::current_dir() {
59 Ok(p) => p,
60 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
61 };
62 let layout = match super::resolve_layout(&cwd) {
63 Ok(layout) => layout,
64 Err(code) => return code,
65 };
66 let layered = match config::read_layered(&layout) {
71 Ok(l) => l,
72 Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
73 };
74 let json = matches!(opts.format, ConfigFormat::Json);
75
76 if let Some(raw_key) = opts.unset.as_deref() {
77 if !opts.args.is_empty() {
78 return super::usage_error("mkit config --unset takes no positional arguments");
79 }
80 return run_unset(&layout, &layered, raw_key, opts.local, opts.global);
81 }
82
83 match opts.args.len() {
84 0 => return show_all(&layered.merged, json),
85 1 => {
86 return show_one(
87 &layered.merged,
88 &config::normalize_config_key(&opts.args[0]),
89 json,
90 );
91 }
92 2 => {}
93 _ => {
94 return super::usage_error(&format!(
95 "too many arguments: expected 0, 1, or 2 positional args, got {}",
96 opts.args.len()
97 ));
98 }
99 }
100 let key_normalized = config::normalize_config_key(&opts.args[0]);
107 let key = key_normalized.as_str();
108 let value = opts.args[1].as_str();
109 if key == "admission_helper" && !std::path::Path::new(value).is_absolute() {
110 return emit_err(
111 "admission_helper must be an absolute path",
112 exit::CONFIG_ERROR,
113 );
114 }
115 if remote_admission_name(key).is_some() {
116 for header in value.split(',').map(str::trim).filter(|s| !s.is_empty()) {
117 let bearer = std::env::var("MKIT_API_TOKEN").is_ok_and(|s| !s.is_empty());
118 if mkit_transport_connect::admission::is_reserved(header, bearer) {
119 return emit_err(
120 &format!("reserved admission header `{header}`"),
121 exit::CONFIG_ERROR,
122 );
123 }
124 }
125 }
126 if let Err(e) = config::validate_value(value) {
127 return emit_err(&format!("invalid value: {e}"), exit::CONFIG_ERROR);
128 }
129 if key == "grant.webauthn_rp"
130 && let Err(e) = crate::grants::parse_relying_parties(&[value.to_owned()])
131 {
132 return emit_err(&format!("{key}: {e}"), exit::CONFIG_ERROR);
133 }
134 let normalized_value = if key == "user.identity" {
135 match config::expand_user_identity(value) {
136 Ok(v) => v,
137 Err(e) => return emit_err(&format!("{key}: {e}"), exit::CONFIG_ERROR),
138 }
139 } else {
140 value.to_owned()
141 };
142 if is_path_key(key)
145 && let Err(e) = config::validate_key_path(&normalized_value)
146 {
147 return emit_err(&format!("{e}"), exit::CONFIG_ERROR);
148 }
149 warn_if_alias_without_identity(&layered.merged, key);
150 let forbidden = config::is_repo_forbidden_key(key);
151 if opts.local && forbidden {
152 return emit_err(
153 &format!(
154 "config key `{key}` cannot be stored in the repo (--local); it is user-scoped only"
155 ),
156 exit::CONFIG_ERROR,
157 );
158 }
159 if forbidden || opts.global {
165 return write_user_scoped(key, &normalized_value);
166 }
167 let mut repo_cfg = layered.repo;
171 if let Err(code) = apply(&mut repo_cfg, key, &normalized_value) {
172 return code;
173 }
174 match config::write(&layout, &repo_cfg) {
175 Ok(()) => exit::OK,
176 Err(e) => emit_err(&format!("write config: {e}"), exit::CANTCREAT),
177 }
178}
179
180fn run_unset(
185 layout: &mkit_core::layout::RepoLayout,
186 layered: &config::LayeredConfig,
187 raw_key: &str,
188 local: bool,
189 global: bool,
190) -> u8 {
191 let key_normalized = config::normalize_config_key(raw_key);
192 let key = key_normalized.as_str();
193 if lookup(&Config::default(), key).is_none() {
194 return emit_err(&format!("unknown config key: {key}"), exit::CONFIG_ERROR);
195 }
196 let forbidden = config::is_repo_forbidden_key(key);
197 if local && forbidden {
198 return emit_err(
199 &format!(
200 "config key `{key}` cannot be unset from the repo (--local); it is user-scoped only"
201 ),
202 exit::CONFIG_ERROR,
203 );
204 }
205 if forbidden || global {
206 return match config::remove_user_kv(key) {
207 Ok(removed) => {
208 if removed {
209 let mut stderr = std::io::stderr().lock();
210 let _ = writeln!(
211 stderr,
212 "removed `{key}` from user-scoped config at {}",
213 config::user_config_path().display()
214 );
215 }
216 exit::OK
217 }
218 Err(e) => emit_err(
219 &format!(
220 "remove user config at {}: {e}",
221 config::user_config_path().display()
222 ),
223 exit::CANTCREAT,
224 ),
225 };
226 }
227 let mut repo_cfg = layered.repo.clone();
228 match unset_repo_key(&mut repo_cfg, key) {
229 Ok(_removed) => {}
230 Err(code) => return code,
231 }
232 match config::write(layout, &repo_cfg) {
233 Ok(()) => exit::OK,
234 Err(e) => emit_err(&format!("write config: {e}"), exit::CANTCREAT),
235 }
236}
237
238fn unset_repo_key(cfg: &mut Config, key: &str) -> Result<bool, u8> {
246 fn take_nonempty(field: &mut String) -> bool {
247 if field.is_empty() {
248 false
249 } else {
250 field.clear();
251 true
252 }
253 }
254 match key {
255 "user.name" => Ok(take_nonempty(&mut cfg.user_name)),
256 "user.email" => Ok(take_nonempty(&mut cfg.user_email)),
257 "default_branch" => Ok(take_nonempty(&mut cfg.default_branch)),
258 "durability.objects" => Ok(take_nonempty(&mut cfg.durability_objects)),
259 "remote_endpoint" => Ok(take_nonempty(&mut cfg.remote_endpoint)),
260 "remote_bucket" => Ok(take_nonempty(&mut cfg.remote_bucket)),
261 "remote_type" => Ok(take_nonempty(&mut cfg.remote_type)),
262 "transport_auth" => Ok(take_nonempty(&mut cfg.transport_auth)),
263 "http.sslcainfo" => Ok(take_nonempty(&mut cfg.http_ssl_ca_info)),
264 k if config::is_core_section(k) => match config::core_allowed_suffix(k) {
265 Some(suffix) => Ok(cfg.core.remove(&suffix).is_some()),
266 None => Err(emit_err(
267 &format!("unknown config key: {key}"),
268 exit::CONFIG_ERROR,
269 )),
270 },
271 _ => Err(emit_err(
272 &format!("unknown config key: {key}"),
273 exit::CONFIG_ERROR,
274 )),
275 }
276}
277
278fn is_path_key(key: &str) -> bool {
279 matches!(
280 key,
281 "signing_key"
282 | "ssh.user_known_hosts_file"
283 | "ssh.identity_file"
284 | "attest.external_signer_path"
285 | "attest.secp256k1_key_path"
286 | "attest.p256_key_path"
287 )
288}
289
290fn remote_admission_name(key: &str) -> Option<&str> {
291 let name = key
292 .strip_prefix("remote.")?
293 .strip_suffix(".admission_headers")?;
294 (!name.is_empty() && !name.contains('.') && mkit_core::refs::validate_ref_name_grammar(name))
295 .then_some(name)
296}
297
298fn warn_if_alias_without_identity(merged: &Config, key: &str) {
312 if !matches!(key, "user.name" | "user.email") {
313 return;
314 }
315 if !merged.user_identity.is_empty()
316 || !merged.user_name.is_empty()
317 || !merged.user_email.is_empty()
318 {
319 return;
320 }
321 let mut stderr = std::io::stderr().lock();
322 let _ = writeln!(
323 stderr,
324 "warning: `{key}` is a git-compatibility alias and does not affect the signed commit \
325 author; commits are signed as `user.identity` (unset) or, by default, the signing \
326 key's identity — set it with `mkit config user.identity <value>` (SPEC-SIGNING §6)"
327 );
328}
329
330fn write_user_scoped(key: &str, value: &str) -> u8 {
331 match config::write_user_kv(key, value) {
332 Ok(()) => {
333 let mut stderr = std::io::stderr().lock();
334 let _ = writeln!(
335 stderr,
336 "wrote `{key}` to user-scoped config at {}",
337 config::user_config_path().display()
338 );
339 exit::OK
340 }
341 Err(e) => emit_err(
342 &format!(
343 "write user config at {}: {e}",
344 config::user_config_path().display()
345 ),
346 exit::CANTCREAT,
347 ),
348 }
349}
350
351fn apply(cfg: &mut Config, key: &str, value: &str) -> Result<(), u8> {
356 match key {
357 "user.name" => value.clone_into(&mut cfg.user_name),
362 "user.email" => value.clone_into(&mut cfg.user_email),
363 "default_branch" => value.clone_into(&mut cfg.default_branch),
364 "durability.objects" => match value.trim().to_ascii_lowercase().as_str() {
369 "" | "batch" | "per-object" | "per_object" => {
370 value.clone_into(&mut cfg.durability_objects);
371 }
372 _ => {
373 return Err(emit_err(
374 &format!(
375 "invalid value for durability.objects: `{value}` (expected `batch` or `per-object`)"
376 ),
377 exit::CONFIG_ERROR,
378 ));
379 }
380 },
381 "remote_endpoint" => value.clone_into(&mut cfg.remote_endpoint),
382 "remote_bucket" => value.clone_into(&mut cfg.remote_bucket),
383 "remote_type" => value.clone_into(&mut cfg.remote_type),
384 "http.sslcainfo" => value.clone_into(&mut cfg.http_ssl_ca_info),
385 "transport_auth" => match value.trim().to_ascii_lowercase().as_str() {
392 "" | "bearer" | "envelope" => value.clone_into(&mut cfg.transport_auth),
393 _ => {
394 return Err(emit_err(
395 &format!(
396 "invalid value for transport_auth: `{value}` (expected `bearer` or `envelope`)"
397 ),
398 exit::CONFIG_ERROR,
399 ));
400 }
401 },
402 "author_mid" => {
403 return Err(emit_err(
404 "config key `author_mid` has been removed; use `user.identity` (mid:<N>)",
405 exit::CONFIG_ERROR,
406 ));
407 }
408 k if config::is_core_section(k) => {
413 let name = k
414 .split_once('.')
415 .map_or("", |(_, n)| n)
416 .to_ascii_lowercase();
417 if let Some(suffix) = config::core_allowed_suffix(k) {
418 cfg.core.insert(suffix, value.to_string());
419 } else if config::CORE_DENIED_KEYS.contains(&name.as_str()) {
420 return Err(emit_err(
421 &format!(
422 "config key `{key}` is not honored by mkit and is rejected for safety"
423 ),
424 exit::CONFIG_ERROR,
425 ));
426 } else {
427 return Err(emit_err(
428 &format!("unknown config key: {key}"),
429 exit::CONFIG_ERROR,
430 ));
431 }
432 }
433 _ => {
434 return Err(emit_err(
435 &format!("unknown config key: {key}"),
436 exit::CONFIG_ERROR,
437 ));
438 }
439 }
440 Ok(())
441}
442
443const CONFIG_KEYS: &[&str] = &[
447 "admission_helper",
448 "attest.default_algorithm",
449 "attest.external_signer_args",
450 "attest.external_signer_path",
451 "attest.external_signer_timeout_secs",
452 "attest.p256_key_path",
453 "attest.secp256k1_key_path",
454 "attest.signer",
455 "default_branch",
456 "durability.objects",
457 "grant.webauthn_rp",
458 "http.sslcainfo",
459 "key.backend",
460 "key.default_ref",
461 "key.ed25519_ref",
462 "key.p256_ref",
463 "key.secp256k1_ref",
464 "remote_bucket",
465 "remote_endpoint",
466 "remote_type",
467 "signer",
468 "signing_key",
469 "ssh.identity_file",
470 "ssh.strict_host_key_checking",
471 "ssh.user_known_hosts_file",
472 "transport_auth",
473 "trusted_remote_endpoint",
474 "user.email",
475 "user.identity",
476 "user.name",
477];
478
479fn lookup<'a>(cfg: &'a Config, key: &str) -> Option<Cow<'a, str>> {
480 if let Some(name) = remote_admission_name(key) {
481 return Some(Cow::Borrowed(
482 cfg.remote_admission_headers
483 .get(name)
484 .map_or("", String::as_str),
485 ));
486 }
487 match key {
488 "admission_helper" => Some(Cow::Borrowed(&cfg.admission_helper)),
489 "user.identity" => Some(Cow::Borrowed(&cfg.user_identity)),
490 "user.name" => Some(Cow::Borrowed(&cfg.user_name)),
491 "user.email" => Some(Cow::Borrowed(&cfg.user_email)),
492 "trusted_remote_endpoint" => Some(Cow::Borrowed(&cfg.trusted_remote_endpoint)),
493 "signing_key" => Some(Cow::Borrowed(&cfg.signing_key)),
494 "default_branch" => Some(Cow::Borrowed(&cfg.default_branch)),
495 "durability.objects" => Some(Cow::Borrowed(&cfg.durability_objects)),
496 "remote_endpoint" => Some(Cow::Borrowed(&cfg.remote_endpoint)),
497 "remote_bucket" => Some(Cow::Borrowed(&cfg.remote_bucket)),
498 "remote_type" => Some(Cow::Borrowed(&cfg.remote_type)),
499 "http.sslcainfo" => Some(Cow::Borrowed(&cfg.http_ssl_ca_info)),
500 "transport_auth" => Some(Cow::Borrowed(&cfg.transport_auth)),
501 "grant.webauthn_rp" => Some(Cow::Owned(cfg.grant_webauthn_rp.join("|"))),
502 "ssh.strict_host_key_checking" => Some(Cow::Borrowed(&cfg.ssh_strict_host_key_checking)),
503 "ssh.user_known_hosts_file" => Some(Cow::Borrowed(&cfg.ssh_user_known_hosts_file)),
504 "ssh.identity_file" => Some(Cow::Borrowed(&cfg.ssh_identity_file)),
505 "signer" => Some(Cow::Borrowed(&cfg.signer)),
506 "key.backend" => Some(Cow::Borrowed(cfg.key.backend_or_fallback())),
507 "key.default_ref" => Some(Cow::Borrowed(cfg.key.default_ref_or_fallback())),
508 "key.ed25519_ref" => Some(Cow::Borrowed(cfg.key.ed25519_ref_or_fallback())),
509 "key.secp256k1_ref" => Some(Cow::Borrowed(cfg.key.secp256k1_ref_or_fallback())),
510 "key.p256_ref" => Some(Cow::Borrowed(cfg.key.p256_ref_or_fallback())),
511 "attest.default_algorithm" => {
512 Some(Cow::Borrowed(cfg.attest.default_algorithm_or_fallback()))
513 }
514 "attest.external_signer_args" => {
515 Some(Cow::Owned(cfg.attest.external_signer_args.join("|")))
516 }
517 "attest.external_signer_path" => Some(Cow::Borrowed(&cfg.attest.external_signer_path)),
518 "attest.external_signer_timeout_secs" => Some(Cow::Owned(
519 cfg.attest
520 .external_signer_timeout_secs
521 .map_or_else(String::new, |s| s.to_string()),
522 )),
523 "attest.secp256k1_key_path" => {
524 Some(Cow::Borrowed(cfg.attest.secp256k1_key_path_or_default()))
525 }
526 "attest.p256_key_path" => Some(Cow::Borrowed(cfg.attest.p256_key_path_or_default())),
527 "attest.signer" => Some(Cow::Borrowed(cfg.attest.signer_or_fallback())),
528 k if config::is_core_section(k) => config::core_allowed_suffix(k).map(|suffix| {
532 cfg.core
533 .get(&suffix)
534 .map_or(Cow::Borrowed(""), |v| Cow::Owned(v.clone()))
535 }),
536 _ => None,
537 }
538}
539
540fn show_all(cfg: &Config, json: bool) -> u8 {
541 let mut stdout = std::io::stdout().lock();
542 if json {
543 let _ = stdout.write_all(b"{");
546 for (i, key) in CONFIG_KEYS.iter().enumerate() {
547 if i > 0 {
548 let _ = stdout.write_all(b",");
549 }
550 let v = lookup(cfg, key).unwrap_or(Cow::Borrowed(""));
551 let _ = write!(
552 stdout,
553 "\"{}\":\"{}\"",
554 format::json_escape(key),
555 format::json_escape(&v)
556 );
557 }
558 for (k, v) in &cfg.core {
560 let _ = write!(
561 stdout,
562 ",\"core.{}\":\"{}\"",
563 format::json_escape(k),
564 format::json_escape(v)
565 );
566 }
567 let _ = stdout.write_all(b"}\n");
568 return exit::OK;
569 }
570 for key in CONFIG_KEYS {
571 let v = lookup(cfg, key).unwrap_or(Cow::Borrowed(""));
572 let _ = writeln!(stdout, "{key} = {v}");
573 }
574 for (k, v) in &cfg.core {
575 let _ = writeln!(stdout, "core.{k} = {v}");
576 }
577 exit::OK
578}
579
580fn show_one(cfg: &Config, key: &str, json: bool) -> u8 {
581 let Some(v) = lookup(cfg, key) else {
582 return emit_err(&format!("unknown config key: {key}"), exit::CONFIG_ERROR);
583 };
584 let mut stdout = std::io::stdout().lock();
585 if json {
586 let _ = writeln!(
587 stdout,
588 "{{\"{}\":\"{}\"}}",
589 format::json_escape(key),
590 format::json_escape(&v)
591 );
592 } else {
593 let _ = writeln!(stdout, "{v}");
594 }
595 exit::OK
596}
597
598use super::error as emit_err;