mkit-cli 0.4.1

The mkit command-line tool: a content-addressed VCS with native attestation support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
//! `mkit attest` — produce a signed DSSE attestation for a commit.
//!
//! ```text
//! mkit attest [--commit <hash>] [--algorithm ed25519|secp256k1|p256]
//!             [--signer repo-key|external|keystore]
//!             [--predicate-type <URI>] [--predicate-file <path>]
//!             [--external-signer-arg <V>]...
//!             [--additional-signer "<spec>"]...
//! ```
//!
//! `--external-signer-arg` is repeatable; each instance adds one
//! argv token to the external signer subprocess. If any are passed,
//! they REPLACE (not append to) `attest.external_signer_args` from
//! `.mkit/config` — per-invocation override for "sign with tag X
//! this one time" that avoids shell-quoting hell.
//!
//! Defaults:
//! * `--commit` — HEAD.
//! * `--algorithm` — `attest.default_algorithm` in config, else `ed25519`.
//! * `--signer` — `attest.signer` in config, else `repo-key`.
//! * `--predicate-type` —
//!   `https://github.com/officialunofficial/mkit/spec/predicate/empty/v1`.
//! * `--predicate-file` — omitted ⇒ `{}`.
//!
//! Multi-signature envelopes are produced by passing one or more
//! `--additional-signer` flags after the primary signer. Each spec is
//! a comma-separated `key=value` list:
//!
//! ```text
//! --additional-signer "algorithm=<algo>,signer=<kind>[,path=<file-or-binary>][,args=<a>|<b>|<c>]"
//! ```
//!
//! The optional `args=` clause is pipe-separated (commas would clash
//! with the outer key=value separator) and applies only when
//! `signer=external`. Each pipe-separated token becomes one argv
//! entry for the child process, same as `--external-signer-arg` on
//! the primary signer.
//!
//! Signers are invoked in order (primary first, then each
//! `--additional-signer` as they appear on the command line) and the
//! resulting `{keyid, sig}` tuples are written into one envelope in
//! that same order. Any signer failure aborts the attest — no
//! partial envelopes are written to disk.
//!
//! On success, prints the att-id (64 hex chars) and exits 0.

use std::io::Write;

use clap::Parser;
use mkit_attest::{Algorithm, Envelope, PAYLOAD_TYPE_IN_TOTO, Sig, Signer, statement, store};
use mkit_core::hash::Hash;
use mkit_core::layout::RepoLayout;
use mkit_core::{hash as hash_mod, refs};

use crate::clap_shim;
use crate::commands::attest_factory::{self, FactoryError};
use crate::config::Config;
use crate::exit;

/// Default predicate type URI — placeholder; real callers pass their own.
///
/// Uses the GitHub-anchored URI scheme defined in
/// `docs/specs/SPEC-ATTESTATIONS.md` §6.4
/// (`https://github.com/officialunofficial/mkit/spec/predicate/<name>/v<n>`)
/// so the only predicate URI mkit ships out-of-the-box points at a
/// location the project actually controls.
const DEFAULT_PREDICATE_TYPE: &str =
    "https://github.com/officialunofficial/mkit/spec/predicate/empty/v1";

/// Hard cap on a `--predicate-file` body (#223). A DSSE predicate is a
/// small JSON object; refusing anything past 1 MiB stops a runaway or
/// hostile file from being slurped whole into memory before the JSON
/// parse even runs.
const MAX_PREDICATE_BYTES: u64 = 1024 * 1024;

#[derive(Debug, Parser)]
#[command(
    name = "mkit attest",
    about = "Produce a signed DSSE attestation for a commit."
)]
#[allow(clippy::struct_field_names)]
struct Args {
    /// Commit hash to attest. Defaults to HEAD.
    #[arg(long, value_name = "HASH")]
    commit: Option<String>,
    /// Algorithm: `ed25519`, `secp256k1`, or `p256`.
    #[arg(long, value_name = "ALG")]
    algorithm: Option<String>,
    /// Signer kind: `repo-key` (default), `external`, or `keystore`.
    #[arg(long, value_name = "KIND")]
    signer: Option<String>,
    /// Predicate type URI.
    #[arg(long = "predicate-type", value_name = "URI")]
    predicate_type: Option<String>,
    /// Path to a JSON predicate body.
    #[arg(long = "predicate-file", value_name = "PATH")]
    predicate_file: Option<String>,
    /// Repeatable `--additional-signer "<spec>"`. Each spec is a
    /// comma-separated `key=value` list parsed downstream — see the
    /// module docstring.
    #[arg(long = "additional-signer", value_name = "SPEC")]
    additional_signers: Vec<String>,
    /// Repeatable `--external-signer-arg <V>`. If any instance is
    /// supplied, the full list REPLACES `attest.external_signer_args`
    /// from config (not appended). Empty list ⇒ flag was not passed.
    ///
    /// `allow_hyphen_values` is set so users can pass values that
    /// start with `-` / `--` (e.g. `--external-signer-arg --tag`)
    /// without quoting — the hand-rolled parser this replaces
    /// accepted those literally and we preserve that.
    #[arg(
        long = "external-signer-arg",
        value_name = "ARG",
        allow_hyphen_values = true
    )]
    external_signer_args_vec: Vec<String>,
}

impl Args {
    /// Convert the raw `Vec<String>` form into `Option<Vec<…>>`:
    /// `None` means "flag was not passed; fall back to config";
    /// `Some(_)` means "flag was passed with these values".
    fn external_signer_args(&self) -> Option<Vec<String>> {
        if self.external_signer_args_vec.is_empty() {
            None
        } else {
            Some(self.external_signer_args_vec.clone())
        }
    }
}

/// Parsed `--additional-signer` spec. The `path` field overrides the
/// per-algorithm key path (for `repo-key`) or the external-signer
/// binary path (for `external`); if unset we fall back to the
/// `[attest]` config section just like the primary signer does.
#[derive(Debug, PartialEq, Eq)]
struct SignerSpec {
    algorithm: Algorithm,
    signer_kind: String,
    path: Option<String>,
    /// Parsed `args=a|b|c` clause. `None` ⇒ fall through to
    /// `attest.external_signer_args`; `Some(vec)` ⇒ override for this
    /// signer only. Pipe-separated on the wire because comma is the
    /// spec's key=value separator.
    args: Option<Vec<String>>,
}

fn parse_signer_spec(s: &str) -> Result<SignerSpec, String> {
    let mut algorithm: Option<Algorithm> = None;
    let mut signer_kind: Option<String> = None;
    let mut path: Option<String> = None;
    let mut args: Option<Vec<String>> = None;
    for part in s.split(',') {
        let part = part.trim();
        if part.is_empty() {
            continue;
        }
        let Some((k, v)) = part.split_once('=') else {
            return Err(format!(
                "--additional-signer spec part '{part}' is not key=value"
            ));
        };
        match k.trim() {
            "algorithm" => {
                let v = v.trim();
                let alg = attest_factory::parse_algorithm(v).map_err(|_| {
                    format!("--additional-signer: unknown algorithm '{v}' — expected one of: ed25519, secp256k1, p256")
                })?;
                algorithm = Some(alg);
            }
            "signer" => {
                let v = v.trim();
                if !matches!(v, "repo-key" | "external") {
                    return Err(format!(
                        "--additional-signer: unknown signer '{v}' — expected one of: repo-key, external"
                    ));
                }
                signer_kind = Some(v.to_owned());
            }
            "path" => {
                path = Some(v.trim().to_owned());
            }
            "args" => {
                // Pipe-separator is a deliberate divergence from the
                // `,`-separator used between spec keys: `,` is already
                // taken, and `|` is the shortest ASCII separator that
                // doesn't need shell-quoting. An empty value means
                // "zero argv" and is valid (overrides a non-empty
                // config explicitly).
                args = Some(crate::config::parse_pipe_list(v.trim()));
            }
            other => {
                return Err(format!("--additional-signer: unknown spec key '{other}'"));
            }
        }
    }
    let algorithm =
        algorithm.ok_or_else(|| "--additional-signer: missing algorithm=...".to_owned())?;
    let signer_kind =
        signer_kind.ok_or_else(|| "--additional-signer: missing signer=...".to_owned())?;
    Ok(SignerSpec {
        algorithm,
        signer_kind,
        path,
        args,
    })
}

#[must_use]
#[allow(clippy::too_many_lines)]
pub fn run(args: &[String]) -> u8 {
    let parsed = match clap_shim::parse::<Args>("mkit attest", args) {
        Ok(o) => o,
        Err(code) => return code,
    };

    let cwd = match std::env::current_dir() {
        Ok(p) => p,
        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
    };
    let layout = match super::resolve_layout(&cwd) {
        Ok(layout) => layout,
        Err(code) => return code,
    };
    if !layout.common_dir().is_dir() {
        return emit_err("not a mkit repo", exit::GENERAL_ERROR);
    }

    let mut cfg = match crate::config::read_or_default(&layout) {
        Ok(c) => c,
        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
    };

    // `--external-signer-arg` REPLACES `attest.external_signer_args`
    // when present. Per-invocation override, intentionally not additive
    // so a user can cleanly reproduce `mkit-sign-se sign --tag demo`
    // without having to remember (or clobber) whatever's in config.
    if let Some(argv) = parsed.external_signer_args() {
        cfg.attest.external_signer_args = argv;
    }

    // --- Resolve commit. --------------------------------------------
    let commit_hash = match resolve_commit(&layout, parsed.commit.as_deref()) {
        Ok(h) => h,
        Err((msg, code)) => return emit_err(&msg, code),
    };

    // --- Read the commit's serialised bytes for the subject digest. --
    // The in-toto Statement subject now carries both a blake3 and a
    // sha256 digest of the SAME bytes (SPEC-ATTESTATIONS §4.2), so we
    // need the raw serialised commit, not just its hash. Reading here
    // (before signing/the lock below) doesn't weaken the existing
    // anti-race protection: the lock-held re-check further down still
    // catches a concurrent `gc` pruning the commit between resolution
    // and the final save.
    let commit_bytes = {
        let obj_store = match mkit_core::store::ObjectStore::open(&layout) {
            Ok(s) => s,
            Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
        };
        match super::read_object_bytes(&obj_store, &commit_hash) {
            Ok(b) => b,
            Err((msg, code)) => return emit_err(&msg, code),
        }
    };

    // --- Resolve primary algorithm + signer. ------------------------
    let alg_str = parsed
        .algorithm
        .clone()
        .unwrap_or_else(|| cfg.attest.default_algorithm_or_fallback().to_owned());
    let algorithm = match attest_factory::parse_algorithm(&alg_str) {
        Ok(a) => a,
        Err(FactoryError::UnknownAlgorithm(s)) => {
            return emit_err(
                &format!("unknown algorithm '{s}' — expected one of: ed25519, secp256k1, p256"),
                exit::USAGE,
            );
        }
        Err(e) => return emit_err(&format!("{e}"), exit::USAGE),
    };
    let signer_kind = parsed
        .signer
        .clone()
        .unwrap_or_else(|| cfg.attest.signer_or_fallback().to_owned());

    let primary_signer = match attest_factory::build_signer(&layout, algorithm, &signer_kind, &cfg)
    {
        Ok(s) => s,
        Err(e) => return emit_err(&format!("{e}"), factory_error_code(&e)),
    };

    // --- Resolve additional signers. --------------------------------
    // Parse ALL specs before building ANY signer so a malformed spec
    // surfaces as a USAGE error without any crypto happening.
    let mut additional_specs: Vec<SignerSpec> = Vec::with_capacity(parsed.additional_signers.len());
    for spec_str in &parsed.additional_signers {
        match parse_signer_spec(spec_str) {
            Ok(s) => additional_specs.push(s),
            Err(e) => return emit_err(&e, exit::USAGE),
        }
    }

    let mut signers: Vec<Box<dyn Signer>> = Vec::with_capacity(1 + additional_specs.len());
    signers.push(primary_signer);
    for spec in &additional_specs {
        let signer = match build_additional_signer(&layout, spec, &cfg) {
            Ok(s) => s,
            Err(e) => return emit_err(&format!("{e}"), factory_error_code(&e)),
        };
        signers.push(signer);
    }

    // --- Build predicate bytes. ------------------------------------
    let predicate_bytes: Vec<u8> = match parsed.predicate_file.as_deref() {
        Some(p) => match read_predicate_file(p) {
            Ok(b) => b,
            Err((msg, code)) => return emit_err(&msg, code),
        },
        None => b"{}".to_vec(),
    };
    let predicate_type = parsed
        .predicate_type
        .unwrap_or_else(|| DEFAULT_PREDICATE_TYPE.to_owned());

    // --- Build Statement. ------------------------------------------
    let stmt_bytes = match statement::for_commit(
        &commit_hash,
        &commit_bytes,
        &predicate_type,
        &predicate_bytes,
    ) {
        Ok(s) => s.into_bytes(),
        Err(
            mkit_attest::Error::PredicateMustBeJsonObject
            | mkit_attest::Error::PredicateNotJsonObject
            | mkit_attest::Error::PredicateNotUtf8,
        ) => {
            return emit_err(
                "--predicate-file must contain a JCS-canonical JSON object",
                exit::DATAERR,
            );
        }
        Err(e) => return emit_err(&format!("statement: {e}"), exit::DATAERR),
    };

    // --- Sign with every signer, aborting on the first failure. -----
    let pae = mkit_attest::pae_of(PAYLOAD_TYPE_IN_TOTO, &stmt_bytes);
    let mut signatures: Vec<Sig> = Vec::with_capacity(signers.len());
    for (idx, signer) in signers.iter_mut().enumerate() {
        let sig_bytes = match signer.sign(&pae) {
            Ok(b) => b,
            Err(e) => {
                return emit_err(
                    &format!("sign (signer #{}): {e}", idx + 1),
                    exit::GENERAL_ERROR,
                );
            }
        };
        let keyid = match signer.keyid() {
            Ok(k) => k,
            Err(e) => {
                return emit_err(
                    &format!("keyid (signer #{}): {e}", idx + 1),
                    exit::GENERAL_ERROR,
                );
            }
        };
        signatures.push(Sig {
            keyid,
            sig: sig_bytes,
        });
    }

    let envelope = Envelope {
        payload_type: PAYLOAD_TYPE_IN_TOTO.to_owned(),
        payload: stmt_bytes,
        signatures,
    };
    let encoded = match envelope.encode() {
        Ok(s) => s,
        Err(e) => return emit_err(&format!("encode envelope: {e}"), exit::DATAERR),
    };

    // --- Save. ----------------------------------------------------
    // Hold the repo lock across the envelope write so a concurrent
    // `gc --grace-secs 0` can't compute its live set (which treats
    // attestation subjects as roots) before this attestation lands and then
    // prune the just-attested commit (#267). The repo was validated above
    // (`layout.common_dir().is_dir()`), so a non-repo reported cleanly. Held
    // tightly, after signing (which may shell out to an external signer),
    // around the write only.
    let _lock = match super::acquire_worktree_lock(&layout) {
        Ok(l) => l,
        Err(code) => return code,
    };
    // The commit was resolved before the lock; re-verify it still exists in
    // the object store now that gc can't run, so we never write an
    // attestation whose subject a concurrent `gc --grace-secs 0` pruned
    // between resolution and this save (#267). (`obj_store` avoids shadowing
    // the `mkit_attest::store` module used for `store::save`.)
    let obj_store = match mkit_core::store::ObjectStore::open(&layout) {
        Ok(s) => s,
        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
    };
    if !obj_store.contains(&commit_hash) {
        return emit_err(
            &format!(
                "attested commit {} no longer exists (pruned concurrently?); aborting",
                hash_mod::to_hex(&commit_hash)
            ),
            exit::CANTCREAT,
        );
    }
    let (att_id, path) = match store::save(&layout, &commit_hash, encoded.as_bytes()) {
        Ok(p) => p,
        Err(e) => return emit_err(&format!("store: {e}"), exit::CANTCREAT),
    };
    let mut stderr = std::io::stderr().lock();
    let _ = writeln!(
        stderr,
        "attested {}{} ({} signature(s))",
        hash_mod::to_hex(&att_id),
        path.display(),
        envelope.signatures.len()
    );
    exit::OK
}

/// Build an additional signer from a parsed spec.
///
/// `path` overrides the per-algorithm key path (repo-key) or the
/// external-signer binary path (external); if unset we fall through to
/// the same `[attest]` config the primary signer uses.
fn build_additional_signer(
    layout: &RepoLayout,
    spec: &SignerSpec,
    base: &Config,
) -> Result<Box<dyn Signer>, FactoryError> {
    match spec.signer_kind.as_str() {
        "repo-key" => {
            // A per-spec path overrides the config-level key path; we
            // synthesise a one-off Config to feed the factory so it
            // still does the load-and-validate dance we want.
            let mut cfg = base.clone();
            if let Some(p) = spec.path.as_deref() {
                // Path-traversal guard at the spec layer, mirroring the
                // user-config validator. A per-spec `path=...` value
                // can come from `--additional-signer` argv or, in the
                // multi-sig flow, from a CI-controlled string. Either
                // way `..` traversal is rejected.
                if let Err(e) = crate::config::validate_key_path(p) {
                    return Err(FactoryError::InvalidKeyFile {
                        path: p.to_owned(),
                        reason: e.to_string(),
                    });
                }
                match spec.algorithm {
                    Algorithm::Ed25519 => p.clone_into(&mut cfg.signing_key),
                    Algorithm::Secp256k1 => p.clone_into(&mut cfg.attest.secp256k1_key_path),
                    Algorithm::P256 => p.clone_into(&mut cfg.attest.p256_key_path),
                    #[cfg(feature = "bls-threshold")]
                    Algorithm::Bls12381Threshold => {
                        return Err(FactoryError::UnknownAlgorithm(
                            "bls12381-thr key path is not yet configurable".to_owned(),
                        ));
                    }
                }
            }
            attest_factory::build_signer(layout, spec.algorithm, "repo-key", &cfg)
        }
        "external" => {
            let mut cfg = base.clone();
            if let Some(p) = spec.path.as_deref() {
                p.clone_into(&mut cfg.attest.external_signer_path);
            }
            // Per-spec `args=...` REPLACES the config-level argv so
            // multi-sig specs can independently drive different
            // external binaries. Absent `args=` ⇒ inherit the primary
            // signer's config value, matching how `path=` falls through.
            if let Some(argv) = spec.args.as_ref() {
                cfg.attest.external_signer_args.clone_from(argv);
            }
            attest_factory::build_signer(layout, spec.algorithm, "external", &cfg)
        }
        other => Err(FactoryError::UnknownSignerKind(other.to_owned())),
    }
}

pub(crate) fn factory_error_code(e: &FactoryError) -> u8 {
    match e {
        FactoryError::UnknownSignerKind(_) | FactoryError::UnknownAlgorithm(_) => exit::USAGE,
        FactoryError::MissingKeyFile { .. } | FactoryError::MissingKeystoreKey { .. } => {
            exit::NOINPUT
        }
        _ => exit::CONFIG_ERROR,
    }
}

/// Read a `--predicate-file` with a size cap (#223). Stats the file
/// first so an oversized predicate is rejected before any large read,
/// then reads with a bounded `take` as defence-in-depth against a file
/// that grows between the stat and the read.
fn read_predicate_file(path: &str) -> Result<Vec<u8>, (String, u8)> {
    use std::io::Read;
    let meta = std::fs::metadata(path)
        .map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
    if meta.len() > MAX_PREDICATE_BYTES {
        return Err((
            format!("predicate file '{path}' exceeds {MAX_PREDICATE_BYTES}-byte cap"),
            exit::DATAERR,
        ));
    }
    let file = std::fs::File::open(path)
        .map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
    let mut data = Vec::new();
    file.take(MAX_PREDICATE_BYTES + 1)
        .read_to_end(&mut data)
        .map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
    if data.len() as u64 > MAX_PREDICATE_BYTES {
        return Err((
            format!("predicate file '{path}' exceeds {MAX_PREDICATE_BYTES}-byte cap"),
            exit::DATAERR,
        ));
    }
    Ok(data)
}

/// Parse `--commit` value or fall back to HEAD.
fn resolve_commit(layout: &RepoLayout, flag: Option<&str>) -> Result<Hash, (String, u8)> {
    if let Some(hex) = flag {
        return hash_mod::from_hex(hex)
            .map_err(|e| (format!("bad --commit hash: {e}"), exit::DATAERR));
    }
    match refs::resolve_head(layout) {
        Ok(Some(h)) => Ok(h),
        Ok(None) => Err(("HEAD has no commit yet".to_owned(), exit::GENERAL_ERROR)),
        Err(e) => Err((format!("read HEAD: {e}"), exit::GENERAL_ERROR)),
    }
}

use super::error as emit_err;

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    /// Test-only adapter: drive the clap-derive parser with just the
    /// trailing args (no `mkit attest` argv[0]).
    fn parse_args(args: &[String]) -> Result<Args, clap::Error> {
        let mut full: Vec<String> = vec!["mkit attest".into()];
        full.extend_from_slice(args);
        Args::try_parse_from(full)
    }

    #[test]
    fn parse_args_accepts_all_flags() {
        let args = vec![
            "--commit".into(),
            "abc".into(),
            "--algorithm".into(),
            "p256".into(),
            "--signer".into(),
            "external".into(),
            "--predicate-type".into(),
            "https://example.com/p".into(),
            "--predicate-file".into(),
            "/tmp/x.json".into(),
        ];
        let p = parse_args(&args).unwrap();
        assert_eq!(p.commit.as_deref(), Some("abc"));
        assert_eq!(p.algorithm.as_deref(), Some("p256"));
        assert_eq!(p.signer.as_deref(), Some("external"));
        assert_eq!(p.predicate_type.as_deref(), Some("https://example.com/p"));
        assert_eq!(p.predicate_file.as_deref(), Some("/tmp/x.json"));
        assert!(p.additional_signers.is_empty());
    }

    #[test]
    fn parse_args_collects_repeatable_external_signer_args() {
        let args = vec![
            "--external-signer-arg".into(),
            "sign".into(),
            "--external-signer-arg".into(),
            "--tag".into(),
            "--external-signer-arg".into(),
            "demo".into(),
        ];
        let p = parse_args(&args).unwrap();
        let expected = vec!["sign".to_owned(), "--tag".to_owned(), "demo".to_owned()];
        assert_eq!(p.external_signer_args(), Some(expected));
    }

    #[test]
    fn parse_args_external_signer_arg_none_when_absent() {
        // Distinguishes "flag not passed" (None → fall through to config)
        // from "flag passed with no values" (impossible: the flag needs
        // a value).
        let p = parse_args(&[]).unwrap();
        assert!(p.external_signer_args().is_none());
    }

    #[test]
    fn parse_args_collects_multiple_additional_signers() {
        let args = vec![
            "--additional-signer".into(),
            "algorithm=ed25519,signer=repo-key".into(),
            "--additional-signer".into(),
            "algorithm=p256,signer=external,path=/x".into(),
        ];
        let p = parse_args(&args).unwrap();
        assert_eq!(p.additional_signers.len(), 2);
        assert_eq!(p.additional_signers[0], "algorithm=ed25519,signer=repo-key");
    }

    #[test]
    fn parse_args_rejects_unknown() {
        let args = vec!["--bogus".into(), "x".into()];
        assert!(parse_args(&args).is_err());
    }

    #[test]
    fn parse_signer_spec_ok() {
        let s = parse_signer_spec("algorithm=secp256k1,signer=repo-key,path=k.key").unwrap();
        assert_eq!(s.algorithm, Algorithm::Secp256k1);
        assert_eq!(s.signer_kind, "repo-key");
        assert_eq!(s.path.as_deref(), Some("k.key"));
    }

    #[test]
    fn parse_signer_spec_with_args() {
        let s = parse_signer_spec(
            "algorithm=p256,signer=external,path=/usr/bin/signer,args=sign|--tag|demo",
        )
        .unwrap();
        assert_eq!(s.algorithm, Algorithm::P256);
        assert_eq!(s.signer_kind, "external");
        assert_eq!(s.path.as_deref(), Some("/usr/bin/signer"));
        assert_eq!(
            s.args.as_deref(),
            Some(["sign".to_owned(), "--tag".to_owned(), "demo".to_owned()].as_slice())
        );
    }

    #[test]
    fn parse_signer_spec_args_empty_means_zero_argv() {
        // `args=` with no value is a valid override that means "this
        // signer gets zero argv, regardless of config." Distinct from
        // omitting `args=` entirely (which inherits from config).
        let s = parse_signer_spec("algorithm=ed25519,signer=external,args=").unwrap();
        assert_eq!(s.args.as_deref(), Some([].as_slice()));
    }

    #[test]
    fn parse_signer_spec_without_path() {
        let s = parse_signer_spec("algorithm=p256,signer=external").unwrap();
        assert_eq!(s.algorithm, Algorithm::P256);
        assert_eq!(s.signer_kind, "external");
        assert!(s.path.is_none());
    }

    #[test]
    fn parse_signer_spec_missing_algorithm() {
        let e = parse_signer_spec("signer=repo-key").unwrap_err();
        assert!(e.contains("algorithm"), "{e}");
    }

    #[test]
    fn parse_signer_spec_missing_signer() {
        let e = parse_signer_spec("algorithm=ed25519").unwrap_err();
        assert!(e.contains("signer"), "{e}");
    }

    #[test]
    fn parse_signer_spec_unknown_algorithm() {
        let e = parse_signer_spec("algorithm=rsa,signer=repo-key").unwrap_err();
        assert!(e.contains("rsa"), "{e}");
    }

    #[test]
    fn parse_signer_spec_unknown_signer_kind() {
        let e = parse_signer_spec("algorithm=ed25519,signer=sigstore").unwrap_err();
        assert!(e.contains("sigstore"), "{e}");
    }

    #[test]
    fn parse_signer_spec_not_key_value() {
        // Missing comma between key=value pairs — `split_once('=')` swallows
        // the rest into the value of the first key. This surfaces as an
        // "unknown algorithm" error for the bogus algorithm value, which is
        // clear enough for the user to fix.
        let e = parse_signer_spec("algorithm=ed25519 signer=repo-key").unwrap_err();
        assert!(e.contains("algorithm") || e.contains("key=value"), "{e}");
    }

    #[test]
    fn parse_args_all_defaults_when_empty() {
        let p = parse_args(&[]).unwrap();
        assert!(p.commit.is_none());
        assert!(p.algorithm.is_none());
        assert!(p.signer.is_none());
        assert!(p.predicate_type.is_none());
        assert!(p.predicate_file.is_none());
        assert!(p.additional_signers.is_empty());
    }
}