use std::io::Write as _;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use super::trust_roots::{
self, TrustEntry, default_trust_roots_path, keyid_matches_pubkey, warn_if_unsafe_trust_roots,
};
use crate::clap_shim;
use crate::exit;
#[derive(Debug, Parser)]
#[command(
name = "mkit trust",
about = "Manage the commit-history trust-roots file."
)]
struct TrustOpts {
#[command(subcommand)]
command: TrustCommand,
}
#[derive(Debug, Subcommand)]
enum TrustCommand {
Add(AddOpts),
List(ListOpts),
Remove(RemoveOpts),
}
#[derive(Debug, Parser)]
struct AddOpts {
keyid: String,
pubkey_hex: String,
#[arg(long, value_name = "KIND", default_value = "ed25519")]
kind: String,
#[arg(long, value_name = "PATH")]
trust_roots: Option<String>,
#[arg(long)]
force: bool,
}
#[derive(Debug, Parser)]
struct ListOpts {
#[arg(long, value_name = "PATH")]
trust_roots: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Parser)]
struct RemoveOpts {
keyid: String,
#[arg(long, value_name = "PATH")]
trust_roots: Option<String>,
#[arg(long)]
yes: bool,
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<TrustOpts>("mkit trust", args) {
Ok(opts) => opts,
Err(code) => return code,
};
match opts.command {
TrustCommand::Add(opts) => add(&opts),
TrustCommand::List(opts) => list(&opts),
TrustCommand::Remove(opts) => remove(&opts),
}
}
fn resolve_path(flag: Option<&str>) -> Result<PathBuf, u8> {
let path = flag.map_or_else(default_trust_roots_path, PathBuf::from);
let cwd = std::env::current_dir().unwrap_or_default();
let mkit_dir = cwd.join(".mkit");
warn_if_unsafe_trust_roots(&path, &mkit_dir, flag.is_some())?;
Ok(path)
}
fn add(opts: &AddOpts) -> u8 {
let path = match resolve_path(opts.trust_roots.as_deref()) {
Ok(p) => p,
Err(code) => return code,
};
let Some(pk_bytes) = trust_roots::hex_decode(&opts.pubkey_hex) else {
return emit_err(
&format!("bad --pubkey-hex '{}': not valid hex", opts.pubkey_hex),
exit::USAGE,
);
};
if let Some(expected_len) = expected_pubkey_len(&opts.kind)
&& pk_bytes.len() != expected_len
{
return emit_err(
&format!(
"bad pubkey length for kind '{}': expected {expected_len} bytes, got {}",
opts.kind,
pk_bytes.len()
),
exit::USAGE,
);
}
if !keyid_matches_pubkey(&opts.keyid, &pk_bytes) {
return emit_err(
&format!(
"keyid '{}' does not match the given pubkey — a `<algorithm>:<hex>` keyid must \
embed the same hex as --pubkey-hex (or the blake3 digest of it)",
opts.keyid
),
exit::USAGE,
);
}
let mut entries = match trust_roots::load_entries(&path) {
Ok(e) => e,
Err((msg, code)) => return emit_err(&msg, code),
};
if let Some(existing) = entries.iter().position(|e| e.keyid == opts.keyid) {
if !opts.force {
return emit_err(
&format!(
"a trust root for keyid '{}' already exists — pass --force to replace it",
opts.keyid
),
exit::USAGE,
);
}
entries.remove(existing);
}
entries.push(TrustEntry {
keyid: opts.keyid.clone(),
kind: opts.kind.clone(),
pubkey_hex: opts.pubkey_hex.to_ascii_lowercase(),
});
if let Err((msg, code)) = trust_roots::save(&path, &entries) {
return emit_err(&msg, code);
}
let mut stdout = std::io::stdout().lock();
let _ = writeln!(
stdout,
"added {} ({}) to {}",
opts.keyid,
opts.kind,
path.display()
);
exit::OK
}
fn list(opts: &ListOpts) -> u8 {
let path = match resolve_path(opts.trust_roots.as_deref()) {
Ok(p) => p,
Err(code) => return code,
};
let entries = match trust_roots::load_entries(&path) {
Ok(e) => e,
Err((msg, code)) => return emit_err(&msg, code),
};
let mut stdout = std::io::stdout().lock();
if opts.json {
use std::fmt::Write as _;
let mut out = String::from("[");
for (i, e) in entries.iter().enumerate() {
if i > 0 {
out.push(',');
}
let _ = write!(
out,
"{{\"keyid\":{:?},\"kind\":{:?},\"pubkey_hex\":{:?}}}",
e.keyid, e.kind, e.pubkey_hex
);
}
out.push(']');
let _ = writeln!(stdout, "{out}");
} else if entries.is_empty() {
let _ = writeln!(stdout, "no trust roots in {}", path.display());
} else {
for e in &entries {
let _ = writeln!(stdout, "{} [{}] {}", e.keyid, e.kind, e.pubkey_hex);
}
}
exit::OK
}
fn remove(opts: &RemoveOpts) -> u8 {
if !opts.yes {
return emit_err("mkit trust remove requires --yes", exit::USAGE);
}
let path = match resolve_path(opts.trust_roots.as_deref()) {
Ok(p) => p,
Err(code) => return code,
};
let mut entries = match trust_roots::load_entries(&path) {
Ok(e) => e,
Err((msg, code)) => return emit_err(&msg, code),
};
let Some(pos) = entries.iter().position(|e| e.keyid == opts.keyid) else {
return emit_err(
&format!("no trust root registered for keyid '{}'", opts.keyid),
exit::GENERAL_ERROR,
);
};
entries.remove(pos);
if let Err((msg, code)) = trust_roots::save(&path, &entries) {
return emit_err(&msg, code);
}
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "removed {} from {}", opts.keyid, path.display());
exit::OK
}
fn expected_pubkey_len(kind: &str) -> Option<usize> {
match kind {
"ed25519" => Some(32),
#[cfg(feature = "bls-threshold")]
"bls12381-thr" => Some(mkit_attest::BLS_THRESHOLD_PUBLIC_KEY_SIZE),
_ => None,
}
}
use super::error as emit_err;
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn parse_args(args: &[String]) -> Result<TrustOpts, clap::Error> {
let mut full: Vec<String> = vec!["mkit trust".into()];
full.extend_from_slice(args);
TrustOpts::try_parse_from(full)
}
#[test]
fn parse_add_defaults_kind_to_ed25519() {
let args = vec!["add".into(), "keyid".into(), "aa".into()];
let TrustCommand::Add(opts) = parse_args(&args).unwrap().command else {
panic!("expected Add");
};
assert_eq!(opts.kind, "ed25519");
assert!(!opts.force);
}
#[test]
fn parse_remove_requires_yes_flag_at_runtime_not_parse_time() {
let args = vec!["remove".into(), "keyid".into()];
let TrustCommand::Remove(opts) = parse_args(&args).unwrap().command else {
panic!("expected Remove");
};
assert!(!opts.yes);
}
#[test]
fn add_list_remove_round_trip() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("trust-roots.toml");
let hex = "11".repeat(32);
let keyid = format!("ed25519:{hex}");
let rc = add(&AddOpts {
keyid: keyid.clone(),
pubkey_hex: hex.clone(),
kind: "ed25519".into(),
trust_roots: Some(path.to_string_lossy().into_owned()),
force: false,
});
assert_eq!(rc, exit::OK);
let entries = trust_roots::load_entries(&path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].keyid, keyid);
let rc = remove(&RemoveOpts {
keyid: keyid.clone(),
trust_roots: Some(path.to_string_lossy().into_owned()),
yes: true,
});
assert_eq!(rc, exit::OK);
assert!(trust_roots::load_entries(&path).unwrap().is_empty());
let _ = fs::remove_dir_all(td.path());
}
#[test]
fn add_rejects_keyid_pubkey_mismatch() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("trust-roots.toml");
let hex = "22".repeat(32);
let rc = add(&AddOpts {
keyid: format!("ed25519:{}", "ff".repeat(32)),
pubkey_hex: hex,
kind: "ed25519".into(),
trust_roots: Some(path.to_string_lossy().into_owned()),
force: false,
});
assert_eq!(rc, exit::USAGE);
}
#[test]
fn add_without_force_refuses_duplicate_keyid() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("trust-roots.toml");
let hex = "33".repeat(32);
let keyid = format!("ed25519:{hex}");
let make = || AddOpts {
keyid: keyid.clone(),
pubkey_hex: hex.clone(),
kind: "ed25519".into(),
trust_roots: Some(path.to_string_lossy().into_owned()),
force: false,
};
assert_eq!(add(&make()), exit::OK);
assert_eq!(add(&make()), exit::USAGE);
let mut forced = make();
forced.force = true;
assert_eq!(add(&forced), exit::OK);
assert_eq!(trust_roots::load_entries(&path).unwrap().len(), 1);
}
#[test]
fn remove_without_yes_is_refused() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("trust-roots.toml");
let rc = remove(&RemoveOpts {
keyid: "anything".into(),
trust_roots: Some(path.to_string_lossy().into_owned()),
yes: false,
});
assert_eq!(rc, exit::USAGE);
}
#[test]
fn remove_unknown_keyid_is_an_error() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("trust-roots.toml");
let rc = remove(&RemoveOpts {
keyid: "nope".into(),
trust_roots: Some(path.to_string_lossy().into_owned()),
yes: true,
});
assert_eq!(rc, exit::GENERAL_ERROR);
}
#[test]
fn list_json_emits_valid_array_shape() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("trust-roots.toml");
let hex = "44".repeat(32);
let keyid = format!("ed25519:{hex}");
add(&AddOpts {
keyid,
pubkey_hex: hex,
kind: "ed25519".into(),
trust_roots: Some(path.to_string_lossy().into_owned()),
force: false,
});
let rc = list(&ListOpts {
trust_roots: Some(path.to_string_lossy().into_owned()),
json: true,
});
assert_eq!(rc, exit::OK);
}
}