Skip to main content

mkit_cli/commands/
attest.rs

1//! `mkit attest` — produce a signed DSSE attestation for a commit.
2//!
3//! ```text
4//! mkit attest [--commit <hash>] [--algorithm ed25519|secp256k1|p256]
5//!             [--signer repo-key|external|keystore]
6//!             [--predicate-type <URI>] [--predicate-file <path>]
7//!             [--external-signer-arg <V>]...
8//!             [--additional-signer "<spec>"]...
9//! ```
10//!
11//! `--external-signer-arg` is repeatable; each instance adds one
12//! argv token to the external signer subprocess. If any are passed,
13//! they REPLACE (not append to) `attest.external_signer_args` from
14//! `.mkit/config` — per-invocation override for "sign with tag X
15//! this one time" that avoids shell-quoting hell.
16//!
17//! Defaults:
18//! * `--commit` — HEAD.
19//! * `--algorithm` — `attest.default_algorithm` in config, else `ed25519`.
20//! * `--signer` — `attest.signer` in config, else `repo-key`.
21//! * `--predicate-type` —
22//!   `https://github.com/officialunofficial/mkit/spec/predicate/empty/v1`.
23//! * `--predicate-file` — omitted ⇒ `{}`.
24//!
25//! Multi-signature envelopes are produced by passing one or more
26//! `--additional-signer` flags after the primary signer. Each spec is
27//! a comma-separated `key=value` list:
28//!
29//! ```text
30//! --additional-signer "algorithm=<algo>,signer=<kind>[,path=<file-or-binary>][,args=<a>|<b>|<c>]"
31//! ```
32//!
33//! The optional `args=` clause is pipe-separated (commas would clash
34//! with the outer key=value separator) and applies only when
35//! `signer=external`. Each pipe-separated token becomes one argv
36//! entry for the child process, same as `--external-signer-arg` on
37//! the primary signer.
38//!
39//! Signers are invoked in order (primary first, then each
40//! `--additional-signer` as they appear on the command line) and the
41//! resulting `{keyid, sig}` tuples are written into one envelope in
42//! that same order. Any signer failure aborts the attest — no
43//! partial envelopes are written to disk.
44//!
45//! On success, prints the att-id (64 hex chars) and exits 0.
46
47use std::io::Write;
48
49use clap::Parser;
50use mkit_attest::{Algorithm, Envelope, PAYLOAD_TYPE_IN_TOTO, Sig, Signer, statement, store};
51use mkit_core::hash::Hash;
52use mkit_core::layout::RepoLayout;
53use mkit_core::{hash as hash_mod, refs};
54
55use crate::clap_shim;
56use crate::commands::attest_factory::{self, FactoryError};
57use crate::config::Config;
58use crate::exit;
59
60/// Default predicate type URI — placeholder; real callers pass their own.
61///
62/// Uses the GitHub-anchored URI scheme defined in
63/// `docs/specs/SPEC-ATTESTATIONS.md` §6.4
64/// (`https://github.com/officialunofficial/mkit/spec/predicate/<name>/v<n>`)
65/// so the only predicate URI mkit ships out-of-the-box points at a
66/// location the project actually controls.
67const DEFAULT_PREDICATE_TYPE: &str =
68    "https://github.com/officialunofficial/mkit/spec/predicate/empty/v1";
69
70/// Hard cap on a `--predicate-file` body (#223). A DSSE predicate is a
71/// small JSON object; refusing anything past 1 MiB stops a runaway or
72/// hostile file from being slurped whole into memory before the JSON
73/// parse even runs.
74const MAX_PREDICATE_BYTES: u64 = 1024 * 1024;
75
76#[derive(Debug, Parser)]
77#[command(
78    name = "mkit attest",
79    about = "Produce a signed DSSE attestation for a commit."
80)]
81#[allow(clippy::struct_field_names)]
82struct Args {
83    /// Commit hash to attest. Defaults to HEAD.
84    #[arg(long, value_name = "HASH")]
85    commit: Option<String>,
86    /// Algorithm: `ed25519`, `secp256k1`, or `p256`.
87    #[arg(long, value_name = "ALG")]
88    algorithm: Option<String>,
89    /// Signer kind: `repo-key` (default), `external`, or `keystore`.
90    #[arg(long, value_name = "KIND")]
91    signer: Option<String>,
92    /// Predicate type URI.
93    #[arg(long = "predicate-type", value_name = "URI")]
94    predicate_type: Option<String>,
95    /// Path to a JSON predicate body.
96    #[arg(long = "predicate-file", value_name = "PATH")]
97    predicate_file: Option<String>,
98    /// Repeatable `--additional-signer "<spec>"`. Each spec is a
99    /// comma-separated `key=value` list parsed downstream — see the
100    /// module docstring.
101    #[arg(long = "additional-signer", value_name = "SPEC")]
102    additional_signers: Vec<String>,
103    /// Repeatable `--external-signer-arg <V>`. If any instance is
104    /// supplied, the full list REPLACES `attest.external_signer_args`
105    /// from config (not appended). Empty list ⇒ flag was not passed.
106    ///
107    /// `allow_hyphen_values` is set so users can pass values that
108    /// start with `-` / `--` (e.g. `--external-signer-arg --tag`)
109    /// without quoting — the hand-rolled parser this replaces
110    /// accepted those literally and we preserve that.
111    #[arg(
112        long = "external-signer-arg",
113        value_name = "ARG",
114        allow_hyphen_values = true
115    )]
116    external_signer_args_vec: Vec<String>,
117}
118
119impl Args {
120    /// Convert the raw `Vec<String>` form into `Option<Vec<…>>`:
121    /// `None` means "flag was not passed; fall back to config";
122    /// `Some(_)` means "flag was passed with these values".
123    fn external_signer_args(&self) -> Option<Vec<String>> {
124        if self.external_signer_args_vec.is_empty() {
125            None
126        } else {
127            Some(self.external_signer_args_vec.clone())
128        }
129    }
130}
131
132/// Parsed `--additional-signer` spec. The `path` field overrides the
133/// per-algorithm key path (for `repo-key`) or the external-signer
134/// binary path (for `external`); if unset we fall back to the
135/// `[attest]` config section just like the primary signer does.
136#[derive(Debug, PartialEq, Eq)]
137struct SignerSpec {
138    algorithm: Algorithm,
139    signer_kind: String,
140    path: Option<String>,
141    /// Parsed `args=a|b|c` clause. `None` ⇒ fall through to
142    /// `attest.external_signer_args`; `Some(vec)` ⇒ override for this
143    /// signer only. Pipe-separated on the wire because comma is the
144    /// spec's key=value separator.
145    args: Option<Vec<String>>,
146}
147
148fn parse_signer_spec(s: &str) -> Result<SignerSpec, String> {
149    let mut algorithm: Option<Algorithm> = None;
150    let mut signer_kind: Option<String> = None;
151    let mut path: Option<String> = None;
152    let mut args: Option<Vec<String>> = None;
153    for part in s.split(',') {
154        let part = part.trim();
155        if part.is_empty() {
156            continue;
157        }
158        let Some((k, v)) = part.split_once('=') else {
159            return Err(format!(
160                "--additional-signer spec part '{part}' is not key=value"
161            ));
162        };
163        match k.trim() {
164            "algorithm" => {
165                let v = v.trim();
166                let alg = attest_factory::parse_algorithm(v).map_err(|_| {
167                    format!("--additional-signer: unknown algorithm '{v}' — expected one of: ed25519, secp256k1, p256")
168                })?;
169                algorithm = Some(alg);
170            }
171            "signer" => {
172                let v = v.trim();
173                if !matches!(v, "repo-key" | "external") {
174                    return Err(format!(
175                        "--additional-signer: unknown signer '{v}' — expected one of: repo-key, external"
176                    ));
177                }
178                signer_kind = Some(v.to_owned());
179            }
180            "path" => {
181                path = Some(v.trim().to_owned());
182            }
183            "args" => {
184                // Pipe-separator is a deliberate divergence from the
185                // `,`-separator used between spec keys: `,` is already
186                // taken, and `|` is the shortest ASCII separator that
187                // doesn't need shell-quoting. An empty value means
188                // "zero argv" and is valid (overrides a non-empty
189                // config explicitly).
190                args = Some(crate::config::parse_pipe_list(v.trim()));
191            }
192            other => {
193                return Err(format!("--additional-signer: unknown spec key '{other}'"));
194            }
195        }
196    }
197    let algorithm =
198        algorithm.ok_or_else(|| "--additional-signer: missing algorithm=...".to_owned())?;
199    let signer_kind =
200        signer_kind.ok_or_else(|| "--additional-signer: missing signer=...".to_owned())?;
201    Ok(SignerSpec {
202        algorithm,
203        signer_kind,
204        path,
205        args,
206    })
207}
208
209#[must_use]
210#[allow(clippy::too_many_lines)]
211pub fn run(args: &[String]) -> u8 {
212    let parsed = match clap_shim::parse::<Args>("mkit attest", args) {
213        Ok(o) => o,
214        Err(code) => return code,
215    };
216
217    let cwd = match std::env::current_dir() {
218        Ok(p) => p,
219        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
220    };
221    let layout = match super::resolve_layout(&cwd) {
222        Ok(layout) => layout,
223        Err(code) => return code,
224    };
225    if !layout.common_dir().is_dir() {
226        return emit_err("not a mkit repo", exit::GENERAL_ERROR);
227    }
228
229    let mut cfg = match crate::config::read_or_default(&layout) {
230        Ok(c) => c,
231        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
232    };
233
234    // `--external-signer-arg` REPLACES `attest.external_signer_args`
235    // when present. Per-invocation override, intentionally not additive
236    // so a user can cleanly reproduce `mkit-sign-se sign --tag demo`
237    // without having to remember (or clobber) whatever's in config.
238    if let Some(argv) = parsed.external_signer_args() {
239        cfg.attest.external_signer_args = argv;
240    }
241
242    // --- Resolve commit. --------------------------------------------
243    let commit_hash = match resolve_commit(&layout, parsed.commit.as_deref()) {
244        Ok(h) => h,
245        Err((msg, code)) => return emit_err(&msg, code),
246    };
247
248    // --- Read the commit's serialised bytes for the subject digest. --
249    // The in-toto Statement subject now carries both a blake3 and a
250    // sha256 digest of the SAME bytes (SPEC-ATTESTATIONS §4.2), so we
251    // need the raw serialised commit, not just its hash. Reading here
252    // (before signing/the lock below) doesn't weaken the existing
253    // anti-race protection: the lock-held re-check further down still
254    // catches a concurrent `gc` pruning the commit between resolution
255    // and the final save.
256    let commit_bytes = {
257        let obj_store = match mkit_core::store::ObjectStore::open(&layout) {
258            Ok(s) => s,
259            Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
260        };
261        match super::read_object_bytes(&obj_store, &commit_hash) {
262            Ok(b) => b,
263            Err((msg, code)) => return emit_err(&msg, code),
264        }
265    };
266
267    // --- Resolve primary algorithm + signer. ------------------------
268    let alg_str = parsed
269        .algorithm
270        .clone()
271        .unwrap_or_else(|| cfg.attest.default_algorithm_or_fallback().to_owned());
272    let algorithm = match attest_factory::parse_algorithm(&alg_str) {
273        Ok(a) => a,
274        Err(FactoryError::UnknownAlgorithm(s)) => {
275            return emit_err(
276                &format!("unknown algorithm '{s}' — expected one of: ed25519, secp256k1, p256"),
277                exit::USAGE,
278            );
279        }
280        Err(e) => return emit_err(&format!("{e}"), exit::USAGE),
281    };
282    let signer_kind = parsed
283        .signer
284        .clone()
285        .unwrap_or_else(|| cfg.attest.signer_or_fallback().to_owned());
286
287    let primary_signer = match attest_factory::build_signer(&layout, algorithm, &signer_kind, &cfg)
288    {
289        Ok(s) => s,
290        Err(e) => return emit_err(&format!("{e}"), factory_error_code(&e)),
291    };
292
293    // --- Resolve additional signers. --------------------------------
294    // Parse ALL specs before building ANY signer so a malformed spec
295    // surfaces as a USAGE error without any crypto happening.
296    let mut additional_specs: Vec<SignerSpec> = Vec::with_capacity(parsed.additional_signers.len());
297    for spec_str in &parsed.additional_signers {
298        match parse_signer_spec(spec_str) {
299            Ok(s) => additional_specs.push(s),
300            Err(e) => return emit_err(&e, exit::USAGE),
301        }
302    }
303
304    let mut signers: Vec<Box<dyn Signer>> = Vec::with_capacity(1 + additional_specs.len());
305    signers.push(primary_signer);
306    for spec in &additional_specs {
307        let signer = match build_additional_signer(&layout, spec, &cfg) {
308            Ok(s) => s,
309            Err(e) => return emit_err(&format!("{e}"), factory_error_code(&e)),
310        };
311        signers.push(signer);
312    }
313
314    // --- Build predicate bytes. ------------------------------------
315    let predicate_bytes: Vec<u8> = match parsed.predicate_file.as_deref() {
316        Some(p) => match read_predicate_file(p) {
317            Ok(b) => b,
318            Err((msg, code)) => return emit_err(&msg, code),
319        },
320        None => b"{}".to_vec(),
321    };
322    let predicate_type = parsed
323        .predicate_type
324        .unwrap_or_else(|| DEFAULT_PREDICATE_TYPE.to_owned());
325
326    // --- Build Statement. ------------------------------------------
327    let stmt_bytes = match statement::for_commit(
328        &commit_hash,
329        &commit_bytes,
330        &predicate_type,
331        &predicate_bytes,
332    ) {
333        Ok(s) => s.into_bytes(),
334        Err(
335            mkit_attest::Error::PredicateMustBeJsonObject
336            | mkit_attest::Error::PredicateNotJsonObject
337            | mkit_attest::Error::PredicateNotUtf8,
338        ) => {
339            return emit_err(
340                "--predicate-file must contain a JCS-canonical JSON object",
341                exit::DATAERR,
342            );
343        }
344        Err(e) => return emit_err(&format!("statement: {e}"), exit::DATAERR),
345    };
346
347    // --- Sign with every signer, aborting on the first failure. -----
348    let pae = mkit_attest::pae_of(PAYLOAD_TYPE_IN_TOTO, &stmt_bytes);
349    let mut signatures: Vec<Sig> = Vec::with_capacity(signers.len());
350    for (idx, signer) in signers.iter_mut().enumerate() {
351        let sig_bytes = match signer.sign(&pae) {
352            Ok(b) => b,
353            Err(e) => {
354                return emit_err(
355                    &format!("sign (signer #{}): {e}", idx + 1),
356                    exit::GENERAL_ERROR,
357                );
358            }
359        };
360        let keyid = match signer.keyid() {
361            Ok(k) => k,
362            Err(e) => {
363                return emit_err(
364                    &format!("keyid (signer #{}): {e}", idx + 1),
365                    exit::GENERAL_ERROR,
366                );
367            }
368        };
369        signatures.push(Sig {
370            keyid,
371            sig: sig_bytes,
372        });
373    }
374
375    let envelope = Envelope {
376        payload_type: PAYLOAD_TYPE_IN_TOTO.to_owned(),
377        payload: stmt_bytes,
378        signatures,
379    };
380    let encoded = match envelope.encode() {
381        Ok(s) => s,
382        Err(e) => return emit_err(&format!("encode envelope: {e}"), exit::DATAERR),
383    };
384
385    // --- Save. ----------------------------------------------------
386    // Hold the repo lock across the envelope write so a concurrent
387    // `gc --grace-secs 0` can't compute its live set (which treats
388    // attestation subjects as roots) before this attestation lands and then
389    // prune the just-attested commit (#267). The repo was validated above
390    // (`layout.common_dir().is_dir()`), so a non-repo reported cleanly. Held
391    // tightly, after signing (which may shell out to an external signer),
392    // around the write only.
393    let _lock = match super::acquire_worktree_lock(&layout) {
394        Ok(l) => l,
395        Err(code) => return code,
396    };
397    // The commit was resolved before the lock; re-verify it still exists in
398    // the object store now that gc can't run, so we never write an
399    // attestation whose subject a concurrent `gc --grace-secs 0` pruned
400    // between resolution and this save (#267). (`obj_store` avoids shadowing
401    // the `mkit_attest::store` module used for `store::save`.)
402    let obj_store = match mkit_core::store::ObjectStore::open(&layout) {
403        Ok(s) => s,
404        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
405    };
406    if !obj_store.contains(&commit_hash) {
407        return emit_err(
408            &format!(
409                "attested commit {} no longer exists (pruned concurrently?); aborting",
410                hash_mod::to_hex(&commit_hash)
411            ),
412            exit::CANTCREAT,
413        );
414    }
415    let (att_id, path) = match store::save(&layout, &commit_hash, encoded.as_bytes()) {
416        Ok(p) => p,
417        Err(e) => return emit_err(&format!("store: {e}"), exit::CANTCREAT),
418    };
419    let mut stderr = std::io::stderr().lock();
420    let _ = writeln!(
421        stderr,
422        "attested {} → {} ({} signature(s))",
423        hash_mod::to_hex(&att_id),
424        path.display(),
425        envelope.signatures.len()
426    );
427    exit::OK
428}
429
430/// Build an additional signer from a parsed spec.
431///
432/// `path` overrides the per-algorithm key path (repo-key) or the
433/// external-signer binary path (external); if unset we fall through to
434/// the same `[attest]` config the primary signer uses.
435fn build_additional_signer(
436    layout: &RepoLayout,
437    spec: &SignerSpec,
438    base: &Config,
439) -> Result<Box<dyn Signer>, FactoryError> {
440    match spec.signer_kind.as_str() {
441        "repo-key" => {
442            // A per-spec path overrides the config-level key path; we
443            // synthesise a one-off Config to feed the factory so it
444            // still does the load-and-validate dance we want.
445            let mut cfg = base.clone();
446            if let Some(p) = spec.path.as_deref() {
447                // Path-traversal guard at the spec layer, mirroring the
448                // user-config validator. A per-spec `path=...` value
449                // can come from `--additional-signer` argv or, in the
450                // multi-sig flow, from a CI-controlled string. Either
451                // way `..` traversal is rejected.
452                if let Err(e) = crate::config::validate_key_path(p) {
453                    return Err(FactoryError::InvalidKeyFile {
454                        path: p.to_owned(),
455                        reason: e.to_string(),
456                    });
457                }
458                match spec.algorithm {
459                    Algorithm::Ed25519 => p.clone_into(&mut cfg.signing_key),
460                    Algorithm::Secp256k1 => p.clone_into(&mut cfg.attest.secp256k1_key_path),
461                    Algorithm::P256 => p.clone_into(&mut cfg.attest.p256_key_path),
462                    #[cfg(feature = "bls-threshold")]
463                    Algorithm::Bls12381Threshold => {
464                        return Err(FactoryError::UnknownAlgorithm(
465                            "bls12381-thr key path is not yet configurable".to_owned(),
466                        ));
467                    }
468                }
469            }
470            attest_factory::build_signer(layout, spec.algorithm, "repo-key", &cfg)
471        }
472        "external" => {
473            let mut cfg = base.clone();
474            if let Some(p) = spec.path.as_deref() {
475                p.clone_into(&mut cfg.attest.external_signer_path);
476            }
477            // Per-spec `args=...` REPLACES the config-level argv so
478            // multi-sig specs can independently drive different
479            // external binaries. Absent `args=` ⇒ inherit the primary
480            // signer's config value, matching how `path=` falls through.
481            if let Some(argv) = spec.args.as_ref() {
482                cfg.attest.external_signer_args.clone_from(argv);
483            }
484            attest_factory::build_signer(layout, spec.algorithm, "external", &cfg)
485        }
486        other => Err(FactoryError::UnknownSignerKind(other.to_owned())),
487    }
488}
489
490pub(crate) fn factory_error_code(e: &FactoryError) -> u8 {
491    match e {
492        FactoryError::UnknownSignerKind(_) | FactoryError::UnknownAlgorithm(_) => exit::USAGE,
493        FactoryError::MissingKeyFile { .. } | FactoryError::MissingKeystoreKey { .. } => {
494            exit::NOINPUT
495        }
496        _ => exit::CONFIG_ERROR,
497    }
498}
499
500/// Read a `--predicate-file` with a size cap (#223). Stats the file
501/// first so an oversized predicate is rejected before any large read,
502/// then reads with a bounded `take` as defence-in-depth against a file
503/// that grows between the stat and the read.
504fn read_predicate_file(path: &str) -> Result<Vec<u8>, (String, u8)> {
505    use std::io::Read;
506    let meta = std::fs::metadata(path)
507        .map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
508    if meta.len() > MAX_PREDICATE_BYTES {
509        return Err((
510            format!("predicate file '{path}' exceeds {MAX_PREDICATE_BYTES}-byte cap"),
511            exit::DATAERR,
512        ));
513    }
514    let file = std::fs::File::open(path)
515        .map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
516    let mut data = Vec::new();
517    file.take(MAX_PREDICATE_BYTES + 1)
518        .read_to_end(&mut data)
519        .map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
520    if data.len() as u64 > MAX_PREDICATE_BYTES {
521        return Err((
522            format!("predicate file '{path}' exceeds {MAX_PREDICATE_BYTES}-byte cap"),
523            exit::DATAERR,
524        ));
525    }
526    Ok(data)
527}
528
529/// Parse `--commit` value or fall back to HEAD.
530fn resolve_commit(layout: &RepoLayout, flag: Option<&str>) -> Result<Hash, (String, u8)> {
531    if let Some(hex) = flag {
532        return hash_mod::from_hex(hex)
533            .map_err(|e| (format!("bad --commit hash: {e}"), exit::DATAERR));
534    }
535    match refs::resolve_head(layout) {
536        Ok(Some(h)) => Ok(h),
537        Ok(None) => Err(("HEAD has no commit yet".to_owned(), exit::GENERAL_ERROR)),
538        Err(e) => Err((format!("read HEAD: {e}"), exit::GENERAL_ERROR)),
539    }
540}
541
542use super::error as emit_err;
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use clap::Parser;
548
549    /// Test-only adapter: drive the clap-derive parser with just the
550    /// trailing args (no `mkit attest` argv[0]).
551    fn parse_args(args: &[String]) -> Result<Args, clap::Error> {
552        let mut full: Vec<String> = vec!["mkit attest".into()];
553        full.extend_from_slice(args);
554        Args::try_parse_from(full)
555    }
556
557    #[test]
558    fn parse_args_accepts_all_flags() {
559        let args = vec![
560            "--commit".into(),
561            "abc".into(),
562            "--algorithm".into(),
563            "p256".into(),
564            "--signer".into(),
565            "external".into(),
566            "--predicate-type".into(),
567            "https://example.com/p".into(),
568            "--predicate-file".into(),
569            "/tmp/x.json".into(),
570        ];
571        let p = parse_args(&args).unwrap();
572        assert_eq!(p.commit.as_deref(), Some("abc"));
573        assert_eq!(p.algorithm.as_deref(), Some("p256"));
574        assert_eq!(p.signer.as_deref(), Some("external"));
575        assert_eq!(p.predicate_type.as_deref(), Some("https://example.com/p"));
576        assert_eq!(p.predicate_file.as_deref(), Some("/tmp/x.json"));
577        assert!(p.additional_signers.is_empty());
578    }
579
580    #[test]
581    fn parse_args_collects_repeatable_external_signer_args() {
582        let args = vec![
583            "--external-signer-arg".into(),
584            "sign".into(),
585            "--external-signer-arg".into(),
586            "--tag".into(),
587            "--external-signer-arg".into(),
588            "demo".into(),
589        ];
590        let p = parse_args(&args).unwrap();
591        let expected = vec!["sign".to_owned(), "--tag".to_owned(), "demo".to_owned()];
592        assert_eq!(p.external_signer_args(), Some(expected));
593    }
594
595    #[test]
596    fn parse_args_external_signer_arg_none_when_absent() {
597        // Distinguishes "flag not passed" (None → fall through to config)
598        // from "flag passed with no values" (impossible: the flag needs
599        // a value).
600        let p = parse_args(&[]).unwrap();
601        assert!(p.external_signer_args().is_none());
602    }
603
604    #[test]
605    fn parse_args_collects_multiple_additional_signers() {
606        let args = vec![
607            "--additional-signer".into(),
608            "algorithm=ed25519,signer=repo-key".into(),
609            "--additional-signer".into(),
610            "algorithm=p256,signer=external,path=/x".into(),
611        ];
612        let p = parse_args(&args).unwrap();
613        assert_eq!(p.additional_signers.len(), 2);
614        assert_eq!(p.additional_signers[0], "algorithm=ed25519,signer=repo-key");
615    }
616
617    #[test]
618    fn parse_args_rejects_unknown() {
619        let args = vec!["--bogus".into(), "x".into()];
620        assert!(parse_args(&args).is_err());
621    }
622
623    #[test]
624    fn parse_signer_spec_ok() {
625        let s = parse_signer_spec("algorithm=secp256k1,signer=repo-key,path=k.key").unwrap();
626        assert_eq!(s.algorithm, Algorithm::Secp256k1);
627        assert_eq!(s.signer_kind, "repo-key");
628        assert_eq!(s.path.as_deref(), Some("k.key"));
629    }
630
631    #[test]
632    fn parse_signer_spec_with_args() {
633        let s = parse_signer_spec(
634            "algorithm=p256,signer=external,path=/usr/bin/signer,args=sign|--tag|demo",
635        )
636        .unwrap();
637        assert_eq!(s.algorithm, Algorithm::P256);
638        assert_eq!(s.signer_kind, "external");
639        assert_eq!(s.path.as_deref(), Some("/usr/bin/signer"));
640        assert_eq!(
641            s.args.as_deref(),
642            Some(["sign".to_owned(), "--tag".to_owned(), "demo".to_owned()].as_slice())
643        );
644    }
645
646    #[test]
647    fn parse_signer_spec_args_empty_means_zero_argv() {
648        // `args=` with no value is a valid override that means "this
649        // signer gets zero argv, regardless of config." Distinct from
650        // omitting `args=` entirely (which inherits from config).
651        let s = parse_signer_spec("algorithm=ed25519,signer=external,args=").unwrap();
652        assert_eq!(s.args.as_deref(), Some([].as_slice()));
653    }
654
655    #[test]
656    fn parse_signer_spec_without_path() {
657        let s = parse_signer_spec("algorithm=p256,signer=external").unwrap();
658        assert_eq!(s.algorithm, Algorithm::P256);
659        assert_eq!(s.signer_kind, "external");
660        assert!(s.path.is_none());
661    }
662
663    #[test]
664    fn parse_signer_spec_missing_algorithm() {
665        let e = parse_signer_spec("signer=repo-key").unwrap_err();
666        assert!(e.contains("algorithm"), "{e}");
667    }
668
669    #[test]
670    fn parse_signer_spec_missing_signer() {
671        let e = parse_signer_spec("algorithm=ed25519").unwrap_err();
672        assert!(e.contains("signer"), "{e}");
673    }
674
675    #[test]
676    fn parse_signer_spec_unknown_algorithm() {
677        let e = parse_signer_spec("algorithm=rsa,signer=repo-key").unwrap_err();
678        assert!(e.contains("rsa"), "{e}");
679    }
680
681    #[test]
682    fn parse_signer_spec_unknown_signer_kind() {
683        let e = parse_signer_spec("algorithm=ed25519,signer=sigstore").unwrap_err();
684        assert!(e.contains("sigstore"), "{e}");
685    }
686
687    #[test]
688    fn parse_signer_spec_not_key_value() {
689        // Missing comma between key=value pairs — `split_once('=')` swallows
690        // the rest into the value of the first key. This surfaces as an
691        // "unknown algorithm" error for the bogus algorithm value, which is
692        // clear enough for the user to fix.
693        let e = parse_signer_spec("algorithm=ed25519 signer=repo-key").unwrap_err();
694        assert!(e.contains("algorithm") || e.contains("key=value"), "{e}");
695    }
696
697    #[test]
698    fn parse_args_all_defaults_when_empty() {
699        let p = parse_args(&[]).unwrap();
700        assert!(p.commit.is_none());
701        assert!(p.algorithm.is_none());
702        assert!(p.signer.is_none());
703        assert!(p.predicate_type.is_none());
704        assert!(p.predicate_file.is_none());
705        assert!(p.additional_signers.is_empty());
706    }
707}