1use std::io::Write as _;
23use std::path::PathBuf;
24
25use clap::{Parser, Subcommand};
26
27use super::trust_roots::{
28 self, TrustEntry, default_trust_roots_path, keyid_matches_pubkey, warn_if_unsafe_trust_roots,
29};
30use crate::clap_shim;
31use crate::exit;
32
33#[derive(Debug, Parser)]
34#[command(
35 name = "mkit trust",
36 about = "Manage the commit-history trust-roots file."
37)]
38struct TrustOpts {
39 #[command(subcommand)]
40 command: TrustCommand,
41}
42
43#[derive(Debug, Subcommand)]
44enum TrustCommand {
45 Add(AddOpts),
47 List(ListOpts),
49 Remove(RemoveOpts),
51}
52
53#[derive(Debug, Parser)]
54struct AddOpts {
55 keyid: String,
59 pubkey_hex: String,
64 #[arg(long, value_name = "KIND", default_value = "ed25519")]
68 kind: String,
69 #[arg(long, value_name = "PATH")]
70 trust_roots: Option<String>,
71 #[arg(long)]
73 force: bool,
74}
75
76#[derive(Debug, Parser)]
77struct ListOpts {
78 #[arg(long, value_name = "PATH")]
79 trust_roots: Option<String>,
80 #[arg(long)]
81 json: bool,
82}
83
84#[derive(Debug, Parser)]
85struct RemoveOpts {
86 keyid: String,
87 #[arg(long, value_name = "PATH")]
88 trust_roots: Option<String>,
89 #[arg(long)]
90 yes: bool,
91}
92
93#[must_use]
94pub fn run(args: &[String]) -> u8 {
95 let opts = match clap_shim::parse::<TrustOpts>("mkit trust", args) {
96 Ok(opts) => opts,
97 Err(code) => return code,
98 };
99 match opts.command {
100 TrustCommand::Add(opts) => add(&opts),
101 TrustCommand::List(opts) => list(&opts),
102 TrustCommand::Remove(opts) => remove(&opts),
103 }
104}
105
106fn resolve_path(flag: Option<&str>) -> Result<PathBuf, u8> {
110 let path = flag.map_or_else(default_trust_roots_path, PathBuf::from);
111 let cwd = std::env::current_dir().unwrap_or_default();
118 let mkit_dir = cwd.join(".mkit");
119 warn_if_unsafe_trust_roots(&path, &mkit_dir, flag.is_some())?;
120 Ok(path)
121}
122
123fn add(opts: &AddOpts) -> u8 {
124 let path = match resolve_path(opts.trust_roots.as_deref()) {
125 Ok(p) => p,
126 Err(code) => return code,
127 };
128 let Some(pk_bytes) = trust_roots::hex_decode(&opts.pubkey_hex) else {
129 return emit_err(
130 &format!("bad --pubkey-hex '{}': not valid hex", opts.pubkey_hex),
131 exit::USAGE,
132 );
133 };
134 if let Some(expected_len) = expected_pubkey_len(&opts.kind)
135 && pk_bytes.len() != expected_len
136 {
137 return emit_err(
138 &format!(
139 "bad pubkey length for kind '{}': expected {expected_len} bytes, got {}",
140 opts.kind,
141 pk_bytes.len()
142 ),
143 exit::USAGE,
144 );
145 }
146 if !keyid_matches_pubkey(&opts.keyid, &pk_bytes) {
147 return emit_err(
148 &format!(
149 "keyid '{}' does not match the given pubkey — a `<algorithm>:<hex>` keyid must \
150 embed the same hex as --pubkey-hex (or the blake3 digest of it)",
151 opts.keyid
152 ),
153 exit::USAGE,
154 );
155 }
156 let mut entries = match trust_roots::load_entries(&path) {
157 Ok(e) => e,
158 Err((msg, code)) => return emit_err(&msg, code),
159 };
160 if let Some(existing) = entries.iter().position(|e| e.keyid == opts.keyid) {
161 if !opts.force {
162 return emit_err(
163 &format!(
164 "a trust root for keyid '{}' already exists — pass --force to replace it",
165 opts.keyid
166 ),
167 exit::USAGE,
168 );
169 }
170 entries.remove(existing);
171 }
172 entries.push(TrustEntry {
173 keyid: opts.keyid.clone(),
174 kind: opts.kind.clone(),
175 pubkey_hex: opts.pubkey_hex.to_ascii_lowercase(),
176 });
177 if let Err((msg, code)) = trust_roots::save(&path, &entries) {
178 return emit_err(&msg, code);
179 }
180 let mut stdout = std::io::stdout().lock();
181 let _ = writeln!(
182 stdout,
183 "added {} ({}) to {}",
184 opts.keyid,
185 opts.kind,
186 path.display()
187 );
188 exit::OK
189}
190
191fn list(opts: &ListOpts) -> u8 {
192 let path = match resolve_path(opts.trust_roots.as_deref()) {
193 Ok(p) => p,
194 Err(code) => return code,
195 };
196 let entries = match trust_roots::load_entries(&path) {
197 Ok(e) => e,
198 Err((msg, code)) => return emit_err(&msg, code),
199 };
200 let mut stdout = std::io::stdout().lock();
201 if opts.json {
202 use std::fmt::Write as _;
203 let mut out = String::from("[");
204 for (i, e) in entries.iter().enumerate() {
205 if i > 0 {
206 out.push(',');
207 }
208 let _ = write!(
209 out,
210 "{{\"keyid\":{:?},\"kind\":{:?},\"pubkey_hex\":{:?}}}",
211 e.keyid, e.kind, e.pubkey_hex
212 );
213 }
214 out.push(']');
215 let _ = writeln!(stdout, "{out}");
216 } else if entries.is_empty() {
217 let _ = writeln!(stdout, "no trust roots in {}", path.display());
218 } else {
219 for e in &entries {
220 let _ = writeln!(stdout, "{} [{}] {}", e.keyid, e.kind, e.pubkey_hex);
221 }
222 }
223 exit::OK
224}
225
226fn remove(opts: &RemoveOpts) -> u8 {
227 if !opts.yes {
228 return emit_err("mkit trust remove requires --yes", exit::USAGE);
229 }
230 let path = match resolve_path(opts.trust_roots.as_deref()) {
231 Ok(p) => p,
232 Err(code) => return code,
233 };
234 let mut entries = match trust_roots::load_entries(&path) {
235 Ok(e) => e,
236 Err((msg, code)) => return emit_err(&msg, code),
237 };
238 let Some(pos) = entries.iter().position(|e| e.keyid == opts.keyid) else {
239 return emit_err(
240 &format!("no trust root registered for keyid '{}'", opts.keyid),
241 exit::GENERAL_ERROR,
242 );
243 };
244 entries.remove(pos);
245 if let Err((msg, code)) = trust_roots::save(&path, &entries) {
246 return emit_err(&msg, code);
247 }
248 let mut stdout = std::io::stdout().lock();
249 let _ = writeln!(stdout, "removed {} from {}", opts.keyid, path.display());
250 exit::OK
251}
252
253fn expected_pubkey_len(kind: &str) -> Option<usize> {
254 match kind {
255 "ed25519" => Some(32),
256 #[cfg(feature = "bls-threshold")]
257 "bls12381-thr" => Some(mkit_attest::BLS_THRESHOLD_PUBLIC_KEY_SIZE),
258 _ => None,
262 }
263}
264
265use super::error as emit_err;
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use std::fs;
271
272 fn parse_args(args: &[String]) -> Result<TrustOpts, clap::Error> {
273 let mut full: Vec<String> = vec!["mkit trust".into()];
274 full.extend_from_slice(args);
275 TrustOpts::try_parse_from(full)
276 }
277
278 #[test]
279 fn parse_add_defaults_kind_to_ed25519() {
280 let args = vec!["add".into(), "keyid".into(), "aa".into()];
281 let TrustCommand::Add(opts) = parse_args(&args).unwrap().command else {
282 panic!("expected Add");
283 };
284 assert_eq!(opts.kind, "ed25519");
285 assert!(!opts.force);
286 }
287
288 #[test]
289 fn parse_remove_requires_yes_flag_at_runtime_not_parse_time() {
290 let args = vec!["remove".into(), "keyid".into()];
291 let TrustCommand::Remove(opts) = parse_args(&args).unwrap().command else {
292 panic!("expected Remove");
293 };
294 assert!(!opts.yes);
295 }
296
297 #[test]
298 fn add_list_remove_round_trip() {
299 let td = tempfile::tempdir().unwrap();
300 let path = td.path().join("trust-roots.toml");
301 let hex = "11".repeat(32);
302 let keyid = format!("ed25519:{hex}");
303
304 let rc = add(&AddOpts {
305 keyid: keyid.clone(),
306 pubkey_hex: hex.clone(),
307 kind: "ed25519".into(),
308 trust_roots: Some(path.to_string_lossy().into_owned()),
309 force: false,
310 });
311 assert_eq!(rc, exit::OK);
312
313 let entries = trust_roots::load_entries(&path).unwrap();
314 assert_eq!(entries.len(), 1);
315 assert_eq!(entries[0].keyid, keyid);
316
317 let rc = remove(&RemoveOpts {
318 keyid: keyid.clone(),
319 trust_roots: Some(path.to_string_lossy().into_owned()),
320 yes: true,
321 });
322 assert_eq!(rc, exit::OK);
323 assert!(trust_roots::load_entries(&path).unwrap().is_empty());
324 let _ = fs::remove_dir_all(td.path());
325 }
326
327 #[test]
328 fn add_rejects_keyid_pubkey_mismatch() {
329 let td = tempfile::tempdir().unwrap();
330 let path = td.path().join("trust-roots.toml");
331 let hex = "22".repeat(32);
332 let rc = add(&AddOpts {
333 keyid: format!("ed25519:{}", "ff".repeat(32)),
334 pubkey_hex: hex,
335 kind: "ed25519".into(),
336 trust_roots: Some(path.to_string_lossy().into_owned()),
337 force: false,
338 });
339 assert_eq!(rc, exit::USAGE);
340 }
341
342 #[test]
343 fn add_without_force_refuses_duplicate_keyid() {
344 let td = tempfile::tempdir().unwrap();
345 let path = td.path().join("trust-roots.toml");
346 let hex = "33".repeat(32);
347 let keyid = format!("ed25519:{hex}");
348 let make = || AddOpts {
349 keyid: keyid.clone(),
350 pubkey_hex: hex.clone(),
351 kind: "ed25519".into(),
352 trust_roots: Some(path.to_string_lossy().into_owned()),
353 force: false,
354 };
355 assert_eq!(add(&make()), exit::OK);
356 assert_eq!(add(&make()), exit::USAGE);
357 let mut forced = make();
358 forced.force = true;
359 assert_eq!(add(&forced), exit::OK);
360 assert_eq!(trust_roots::load_entries(&path).unwrap().len(), 1);
361 }
362
363 #[test]
364 fn remove_without_yes_is_refused() {
365 let td = tempfile::tempdir().unwrap();
366 let path = td.path().join("trust-roots.toml");
367 let rc = remove(&RemoveOpts {
368 keyid: "anything".into(),
369 trust_roots: Some(path.to_string_lossy().into_owned()),
370 yes: false,
371 });
372 assert_eq!(rc, exit::USAGE);
373 }
374
375 #[test]
376 fn remove_unknown_keyid_is_an_error() {
377 let td = tempfile::tempdir().unwrap();
378 let path = td.path().join("trust-roots.toml");
379 let rc = remove(&RemoveOpts {
380 keyid: "nope".into(),
381 trust_roots: Some(path.to_string_lossy().into_owned()),
382 yes: true,
383 });
384 assert_eq!(rc, exit::GENERAL_ERROR);
385 }
386
387 #[test]
388 fn list_json_emits_valid_array_shape() {
389 let td = tempfile::tempdir().unwrap();
390 let path = td.path().join("trust-roots.toml");
391 let hex = "44".repeat(32);
392 let keyid = format!("ed25519:{hex}");
393 add(&AddOpts {
394 keyid,
395 pubkey_hex: hex,
396 kind: "ed25519".into(),
397 trust_roots: Some(path.to_string_lossy().into_owned()),
398 force: false,
399 });
400 let rc = list(&ListOpts {
401 trust_roots: Some(path.to_string_lossy().into_owned()),
402 json: true,
403 });
404 assert_eq!(rc, exit::OK);
405 }
406}