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};
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]
52#[allow(clippy::too_many_lines)] // one flat set/show/unset dispatch
53pub 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    // Read both layers: the merged view drives `show`, but a write must
67    // persist ONLY the repo layer — serializing the merged config would
68    // copy user-scoped values (e.g. a private `user.email`) into
69    // `.mkit/config`, which travels with clones.
70    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    // Git treats config section + variable names case-insensitively
101    // (`User.Name` == `user.name`), but subsection names (`remote.<name>`,
102    // `branch.<branch>`) are case-sensitive. Normalize before every
103    // downstream check — crucially BEFORE `REPO_FORBIDDEN_KEYS`, so a
104    // case-variant like `User.Identity` can never bypass the spoof guard
105    // and land in the repo layer.
106    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    // Path-traversal validation for any key whose value is a filesystem
143    // path. Catches `..` even on the user-scoped path.
144    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    // `--global` forces the user-scoped layer even for an otherwise
160    // repo-safe key; a bare `forbidden` key always goes there regardless
161    // of flags (that's the whole point of `REPO_FORBIDDEN_KEYS`); `--local`
162    // is only meaningful (and already validated above) for repo-safe keys,
163    // where it's a no-op since that's the default.
164    if forbidden || opts.global {
165        return write_user_scoped(key, &normalized_value);
166    }
167    // Apply to the repo layer only and persist that — never the merged
168    // config — so user-scoped values are not materialized into the repo
169    // file (see the scope note above).
170    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
180/// `mkit config --unset <key>` — delete `<key>` from the scope a `set`
181/// of it would use (or the scope forced by `--local`/`--global`).
182/// Idempotent: unsetting an already-absent key is a silent success,
183/// like `rm -f`, not an error — only an unknown key name is rejected.
184fn 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
238/// Clear a repo-safe key from the in-memory `Config`, mirroring
239/// [`apply`]'s key match but removing instead of setting. Only
240/// repo-safe keys are reachable here — [`run_unset`] routes
241/// `REPO_FORBIDDEN_KEYS` keys to the user-scoped removal path before
242/// this is called. Returns whether the key had a value to remove
243/// (informational only — [`run_unset`] treats both outcomes as
244/// success).
245fn 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
298/// Warn on stderr the first time `user.name`/`user.email` is set in a
299/// repo that has never set `user.identity` (MKIT-12/#655): these are
300/// git-compatibility aliases that never feed the signed commit author
301/// (see `user_name_does_not_spoof_the_signed_author` in
302/// `tests/config_git_aliases.rs`), and a user coming from git may
303/// otherwise reasonably expect them to.
304///
305/// "First time" is: neither alias nor `user.identity` has a value yet in
306/// the pre-write merged view (`merged`, read before this set is
307/// applied). Once any of the three is set, the warning never fires again
308/// for this repo — including for the second alias, so setting
309/// `user.name` then `user.email` warns only once. Unsetting all three
310/// re-arms it.
311fn 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
351/// Apply a key/value to the in-memory `Config`. Only repo-safe keys
352/// are reachable here — security-sensitive keys (including
353/// `user.identity`) are intercepted by [`run`] via `REPO_FORBIDDEN_KEYS`
354/// and routed to user-scoped storage before this is called.
355fn apply(cfg: &mut Config, key: &str, value: &str) -> Result<(), u8> {
356    match key {
357        // Git-compatibility aliases. Accepted and round-tripped, but
358        // **non-authoritative**: they never feed the signed commit author
359        // (that is `user.identity` / the signing key), so they are
360        // repo-safe and not in `REPO_FORBIDDEN_KEYS`.
361        "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        // SPEC-OBJECTS §10.1 durability escape hatch. Validated at the
365        // set boundary (unlike the lenient config-load fallback) so a
366        // typo can't silently leave the user on the batched default when
367        // they asked for the strict per-object schedule.
368        "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        // Write-auth mode for `mkit+https://`/`mkit+http://` remotes — see
386        // `Config::transport_auth`'s doc comment. Validated here (unlike
387        // the lenient config-load fallback in `config::apply_kv`, which
388        // tolerates unknown values for forward-compat with hand-edited
389        // files) so a typo doesn't silently leave `mkit push` on
390        // bearer-only auth when the user asked for signed envelopes.
391        "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        // Inert git-compat `core.*` keys (section matched case-insensitively):
409        // store the allowlisted ones, and refuse the dangerous ones (they
410        // would change what mkit executes if honored). Anything else under
411        // `core.` is an unknown key.
412        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
443/// Stable schema for the JSON form: every key the CLI knows about,
444/// paired with its value. Keys are emitted in alphabetical order so
445/// the output is deterministic and easy to snapshot-test.
446const 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        // Inert git-compat `core.*` keys (section matched case-insensitively):
529        // an allowlisted key returns its stored value (empty if unset, like
530        // the other keys); anything else under `core.` is unknown.
531        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        // Flat object with every known key. Unset values render as
544        // empty strings, matching the default-mode behaviour.
545        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        // Dynamic, set-only `core.*` git-compat keys.
559        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;