1use 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 #[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]
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 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 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 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 if forbidden || opts.global {
141 return write_user_scoped(key, &normalized_value);
142 }
143 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
156fn 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
214fn 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
286fn apply(cfg: &mut Config, key: &str, value: &str) -> Result<(), u8> {
291 match key {
292 "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 "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 "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 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
377const 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 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 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 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;