Skip to main content

mkit_cli/commands/
commit.rs

1//! `mkit commit` — build a signed commit object from the staging
2//! index.
3//!
4//! Scope:
5//! 1. Accept `-m <msg>` OR spawn `$EDITOR` on a tempfile pre-filled
6//! with `editor::COMMIT_EDITMSG_TEMPLATE`. An empty message
7//! aborts.
8//! 2. Read `.mkit/index` and build a tree via
9//! [`worktree::build_tree_from_index`]. An empty / missing index is
10//! an error — `mkit add <path>` (or `mkit add .`) must come first.
11//! 3. Resolve the author identity in this order:
12//! a. `--author <spec>` CLI flag (overrides everything).
13//! b. `config.user_identity` in `.mkit/config`.
14//! c. Derived from the signing key's public key (default).
15//! 4. Sign the commit, write the `Commit` object, advance
16//! `refs/heads/<current>` and `HEAD`.
17//!
18//! Pre-issue-#102 `mkit commit` walked the worktree directly via
19//! `worktree::build_tree`, ignoring the index entirely. That made
20//! `mkit add` write-only state with no reader and surprised any user
21//! reasoning by analogy from git. Post-#102, the staging area is
22//! load-bearing: only paths in the index land in the commit's tree.
23
24use std::io::Write;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use clap::Parser;
28use mkit_core::index;
29use mkit_core::object::{Commit, Identity, IdentityKind, Object, Tag};
30use mkit_core::refs::{self, Head};
31use mkit_core::serialize;
32use mkit_core::sign::{self, KeyPair};
33use mkit_core::store::ObjectStore;
34use mkit_core::worktree;
35use mkit_keystore::{KeyRef, KeySelector, open_backend};
36
37use crate::clap_shim;
38use crate::config::Config;
39use crate::editor::{COMMIT_EDITMSG_TEMPLATE, spawn_editor};
40use crate::exit;
41use crate::format;
42
43#[derive(Debug, Parser)]
44#[command(
45    name = "mkit commit",
46    about = "Create a signed commit from the staging index."
47)]
48struct CommitOptions {
49    /// Commit message. If omitted, `$EDITOR` is launched.
50    #[arg(short, long)]
51    message: Option<String>,
52    /// Override the author Identity for this commit.
53    #[arg(long = "author", value_name = "SPEC")]
54    author_spec: Option<String>,
55    /// Stage every tracked-and-modified file before committing
56    /// (mirrors `git commit -a`).
57    #[arg(short = 'a', long)]
58    all: bool,
59    /// Replace the current commit (HEAD) instead of adding a new one.
60    ///
61    /// The new commit re-uses HEAD's parent(s) as its own parent(s)
62    /// (so it supersedes HEAD rather than building on it), takes its
63    /// tree from the staging index, and is re-signed. The branch is
64    /// moved to the new commit; the superseded commit becomes
65    /// unreachable. If `-m` is omitted, the previous commit's message
66    /// is reused (no `$EDITOR` is launched).
67    ///
68    /// NOTE: the superseded commit is not deleted — it stays on disk as
69    /// an unreachable object until `mkit gc` ships (see issue #233).
70    #[arg(long)]
71    amend: bool,
72}
73
74#[must_use]
75#[allow(clippy::too_many_lines)]
76pub fn run(args: &[String]) -> u8 {
77    // Split fused `-am<msg>` / `-am <msg>` shortcuts into the
78    // equivalent `-a -m <msg>` so clap sees only canonical forms.
79    let normalised = expand_dash_am(args);
80    let opts = match clap_shim::parse::<CommitOptions>("mkit commit", &normalised) {
81        Ok(o) => o,
82        Err(code) => return code,
83    };
84
85    let cwd = match std::env::current_dir() {
86        Ok(p) => p,
87        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
88    };
89    let store = match super::open_store_configured(&cwd) {
90        Ok(s) => s,
91        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
92    };
93    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
94    let _lock = match super::acquire_worktree_lock(&cwd) {
95        Ok(l) => l,
96        Err(code) => return code,
97    };
98
99    let cfg = match crate::config::read_or_default(&cwd) {
100        Ok(c) => c,
101        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
102    };
103
104    // ---- When amending, load the commit being replaced. ------------
105    // `--amend` re-creates HEAD: the new commit inherits HEAD's parents
106    // (so it supersedes HEAD rather than stacking on it) and, when no
107    // `-m` is given, reuses HEAD's message verbatim.
108    let amend_target = if opts.amend {
109        match resolve_amend_target(&mkit_dir, &store) {
110            Ok(commit) => Some(commit),
111            Err((m, c)) => return emit_err(&m, c),
112        }
113    } else {
114        None
115    };
116
117    // ---- Resolve / prompt for message. -----------------------------
118    // `--amend` without `-m` reuses the superseded commit's message and
119    // never launches `$EDITOR`.
120    let msg = match opts.message {
121        Some(m) => m,
122        None => match &amend_target {
123            Some(prev) => String::from_utf8_lossy(&prev.message).into_owned(),
124            None => match spawn_editor(COMMIT_EDITMSG_TEMPLATE) {
125                Ok(m) if !m.is_empty() => m,
126                Ok(_) => {
127                    return emit_err("empty commit message — aborting", exit::USAGE);
128                }
129                Err(e) => return emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR),
130            },
131        },
132    };
133
134    // ---- Load signer. ----------------------------------------------
135    let mut signer = match load_commit_signer(&cwd, &cfg) {
136        Ok(signer) => signer,
137        Err((msg, code)) => return emit_err(&msg, code),
138    };
139    let signer_public = match signer.public_key() {
140        Ok(public) => public,
141        Err((msg, code)) => return emit_err(&msg, code),
142    };
143
144    // ---- Resolve author. -------------------------------------------
145    // Precedence: --author flag → config.user_identity → pubkey-derived.
146    let author = match resolve_author(
147        opts.author_spec.as_deref(),
148        &cfg.user_identity,
149        &signer_public,
150    ) {
151        Ok(id) => id,
152        Err(e) => return emit_err(&format!("author: {e}"), exit::CONFIG_ERROR),
153    };
154
155    if opts.all
156        && let Err(e) = super::add::stage_tracked_changes(&cwd, &store)
157    {
158        return emit_err(&format!("stage tracked changes: {e}"), exit::GENERAL_ERROR);
159    }
160
161    // Read the staging index. An absent file OR a totally empty
162    // entries vector is a hard error — see module docs and issue
163    // #102. An all-Removed index, by contrast, is a meaningful
164    // changeset (the user is committing deletions) and produces an
165    // empty-tree commit, so we DON'T gate on `staged_count()` (which
166    // excludes Removed entries by design).
167    let idx = match index::read_index(&cwd) {
168        Ok(idx) => idx,
169        Err(e) => return emit_err(&format!("read index: {e}"), exit::GENERAL_ERROR),
170    };
171    if idx.entries.is_empty() {
172        return emit_err(
173            "nothing staged: index is empty; run `mkit add <path>` (or `mkit add .`) before commit",
174            exit::USAGE,
175        );
176    }
177    // One durability batch spans every tree object plus the commit
178    // object; committed below, BEFORE the ref advance that makes the
179    // commit reachable.
180    let batch = store.batch();
181    // Publishing a durable commit — verify staged objects before the tree
182    // references them.
183    let tree_hash = match worktree::build_tree_from_index_with(&store, &batch, &idx, true) {
184        Ok(h) => h,
185        Err(e) => return emit_err(&format!("build tree: {e}"), exit::GENERAL_ERROR),
186    };
187    // Parent selection. A normal commit builds on HEAD. An `--amend`
188    // replaces HEAD, so it adopts HEAD's *parents* — the superseded
189    // commit drops out of the chain entirely.
190    let parents = match &amend_target {
191        Some(prev) => prev.parents.clone(),
192        None => match refs::resolve_head(&mkit_dir) {
193            Ok(Some(h)) => vec![h],
194            _ => vec![],
195        },
196    };
197    let timestamp = SystemTime::now()
198        .duration_since(UNIX_EPOCH)
199        .map_or(0, |d| d.as_secs());
200    let mut unsigned = Commit::new_unannotated(
201        tree_hash,
202        parents,
203        author,
204        signer_public,
205        msg.as_bytes().to_vec(),
206        timestamp,
207        [0u8; 64],
208    );
209    let sig = match signer.sign_commit(&unsigned) {
210        Ok(s) => s,
211        Err((msg, code)) => return emit_err(&msg, code),
212    };
213    unsigned.signature = sig;
214    let bytes = match serialize::serialize(&Object::Commit(unsigned)) {
215        Ok(b) => b,
216        Err(e) => return emit_err(&format!("serialize commit: {e}"), exit::DATAERR),
217    };
218    let commit_hash = match batch.write(&bytes) {
219        Ok(h) => h,
220        Err(e) => return emit_err(&format!("store commit: {e}"), exit::CANTCREAT),
221    };
222    // Make the tree + commit objects durable before anything (recovery
223    // log, HEAD/branch ref, index) references them.
224    if let Err(e) = batch.commit() {
225        return emit_err(&format!("store commit: {e}"), exit::CANTCREAT);
226    }
227    // Amend supersedes the old HEAD. Record it BEFORE moving the branch
228    // (under the worktree lock) so the superseded commit stays
229    // recoverable; abort if the recovery log can't be written.
230    if amend_target.is_some() {
231        match refs::resolve_head(&mkit_dir) {
232            Ok(Some(old_head)) => {
233                let branch = super::head_branch_name(&mkit_dir);
234                if let Err((m, c)) = super::record_superseded(&mkit_dir, "amend", &branch, old_head)
235                {
236                    return emit_err(&m, c);
237                }
238            }
239            Ok(None) => {}
240            Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::DATAERR),
241        }
242    }
243    if let Err((m, c)) = advance_head(&mkit_dir, &commit_hash) {
244        return emit_err(&m, c);
245    }
246    if let Err(e) = super::sync_index_to_tree(&cwd, &store, tree_hash) {
247        return emit_err(&e, exit::CANTCREAT);
248    }
249    let mut stderr = std::io::stderr().lock();
250    let verb = if amend_target.is_some() {
251        "amended"
252    } else {
253        "committed"
254    };
255    let _ = writeln!(
256        stderr,
257        "{verb} {} ({})",
258        format::short_hash(&commit_hash, 8),
259        msg.lines().next().unwrap_or("")
260    );
261    exit::OK
262}
263
264/// Pre-process `args` to canonicalize the legacy `-am<msg>` /
265/// `-am <msg>` shortcut into `-a -m <msg>`. Everything else passes
266/// through unchanged. Clap then sees only canonical forms.
267fn expand_dash_am(args: &[String]) -> Vec<String> {
268    let mut out: Vec<String> = Vec::with_capacity(args.len() + 2);
269    let mut iter = args.iter();
270    while let Some(a) = iter.next() {
271        match a.as_str() {
272            "-am" => {
273                out.push("-a".to_owned());
274                out.push("-m".to_owned());
275                if let Some(next) = iter.next() {
276                    out.push(next.clone());
277                }
278            }
279            s if s.starts_with("-am") && s.len() > 3 => {
280                out.push("-a".to_owned());
281                out.push("-m".to_owned());
282                out.push(s[3..].to_owned());
283            }
284            _ => out.push(a.clone()),
285        }
286    }
287    out
288}
289
290#[cfg(test)]
291mod expand_dash_am_tests {
292    use super::expand_dash_am;
293
294    fn to_strs(args: &[String]) -> Vec<&str> {
295        args.iter().map(String::as_str).collect()
296    }
297
298    #[test]
299    fn fused_dash_am_with_inline_message() {
300        let out = expand_dash_am(&["-amhello".to_owned()]);
301        assert_eq!(to_strs(&out), &["-a", "-m", "hello"]);
302    }
303
304    #[test]
305    fn spaced_dash_am_with_following_message() {
306        let out = expand_dash_am(&["-am".to_owned(), "hello".to_owned()]);
307        assert_eq!(to_strs(&out), &["-a", "-m", "hello"]);
308    }
309
310    #[test]
311    fn unrelated_args_pass_through() {
312        let out = expand_dash_am(&[
313            "-m".to_owned(),
314            "msg".to_owned(),
315            "--author".to_owned(),
316            "id".to_owned(),
317        ]);
318        assert_eq!(to_strs(&out), &["-m", "msg", "--author", "id"]);
319    }
320}
321
322/// Load the Ed25519 signing key. Returns a mapped (message,
323/// exit-code) pair on failure so the caller can route the error
324/// through its usual `emit_err` path.
325///
326/// Auto-generation was removed in combined with a non-atomic
327/// `save_key`, an interrupted keygen could silently rotate the user's
328/// identity (subsequent commits no longer share a signer with prior
329/// ones). The save path is now atomic, but auto-keygen also masks
330/// genuine path-misconfigurations and tooling errors. Users run
331/// `mkit keygen` once, explicitly, and a missing key on `mkit commit`
332/// is now an error.
333fn load_signing_key(
334    cwd: &std::path::Path,
335    rel_signing_key_path: &str,
336) -> Result<KeyPair, (String, u8)> {
337    let key_path = match crate::config::resolve_key_path(cwd, rel_signing_key_path) {
338        Ok(p) => p,
339        Err(e) => return Err((format!("{e}"), exit::CONFIG_ERROR)),
340    };
341    if !key_path.exists() {
342        return Err((
343            format!(
344                "no signing key at {} — run `mkit keygen` to create one",
345                key_path.display()
346            ),
347            exit::NOINPUT,
348        ));
349    }
350    sign::load_key(&key_path).map_err(|e| (format!("load key: {e}"), exit::NOPERM))
351}
352
353pub(super) enum CommitSigner {
354    Legacy(KeyPair),
355    Keystore(Box<dyn mkit_keystore::KeySigner>),
356}
357
358impl CommitSigner {
359    pub(super) fn public_key(&self) -> Result<[u8; 32], (String, u8)> {
360        match self {
361            Self::Legacy(kp) => Ok(kp.public.0),
362            Self::Keystore(signer) => {
363                let public = signer
364                    .public_key()
365                    .map_err(|error| (format!("keystore public key: {error}"), exit::DATAERR))?;
366                public.as_bytes().try_into().map_err(|_| {
367                    (
368                        format!(
369                            "keystore Ed25519 public key must be 32 bytes, got {}",
370                            public.len()
371                        ),
372                        exit::DATAERR,
373                    )
374                })
375            }
376        }
377    }
378
379    /// Sign a [`Tag`] under the distinct tag domain. Mirrors
380    /// [`Self::sign_commit`]: legacy keypairs sign directly, keystore
381    /// signers sign the pre-computed tag signing hash.
382    pub(super) fn sign_tag(&mut self, tag: &Tag) -> Result<[u8; 64], (String, u8)> {
383        match self {
384            Self::Legacy(kp) => sign::sign_tag(tag, kp)
385                .map(|signature| signature.0)
386                .map_err(|error| (format!("sign: {error}"), exit::GENERAL_ERROR)),
387            Self::Keystore(signer) => {
388                let digest = sign::tag_signing_hash(tag)
389                    .map_err(|error| (format!("tag signing hash: {error}"), exit::DATAERR))?;
390                let signature = signer
391                    .sign(&digest)
392                    .map_err(|error| (format!("keystore sign: {error}"), exit::DATAERR))?;
393                signature.try_into().map_err(|signature: Vec<u8>| {
394                    (
395                        format!(
396                            "keystore Ed25519 signature must be 64 bytes, got {}",
397                            signature.len()
398                        ),
399                        exit::DATAERR,
400                    )
401                })
402            }
403        }
404    }
405
406    pub(super) fn sign_commit(&mut self, commit: &Commit) -> Result<[u8; 64], (String, u8)> {
407        match self {
408            Self::Legacy(kp) => sign::sign_commit(commit, kp)
409                .map(|signature| signature.0)
410                .map_err(|error| (format!("sign: {error}"), exit::GENERAL_ERROR)),
411            Self::Keystore(signer) => {
412                let digest = sign::commit_signing_hash(commit)
413                    .map_err(|error| (format!("commit signing hash: {error}"), exit::DATAERR))?;
414                let signature = signer
415                    .sign(&digest)
416                    .map_err(|error| (format!("keystore sign: {error}"), exit::DATAERR))?;
417                signature.try_into().map_err(|signature: Vec<u8>| {
418                    (
419                        format!(
420                            "keystore Ed25519 signature must be 64 bytes, got {}",
421                            signature.len()
422                        ),
423                        exit::DATAERR,
424                    )
425                })
426            }
427        }
428    }
429}
430
431pub(super) fn load_commit_signer(
432    cwd: &std::path::Path,
433    cfg: &Config,
434) -> Result<CommitSigner, (String, u8)> {
435    match cfg.signer.as_str() {
436        "" | "legacy" => load_signing_key(cwd, &cfg.signing_key).map(CommitSigner::Legacy),
437        "keystore" => load_keystore_commit_signer(cfg),
438        other => Err((
439            format!("unknown signer `{other}` — expected `legacy` or `keystore`"),
440            exit::CONFIG_ERROR,
441        )),
442    }
443}
444
445fn load_keystore_commit_signer(cfg: &Config) -> Result<CommitSigner, (String, u8)> {
446    let key_ref = cfg
447        .key
448        .ed25519_ref_or_fallback()
449        .parse::<KeyRef>()
450        .map_err(|error| (format!("key.ed25519_ref: {error}"), exit::CONFIG_ERROR))?;
451    let store = open_backend(key_ref.backend())
452        .map_err(|error| (format!("keystore backend: {error}"), exit::UNAVAILABLE))?;
453    let selector = KeySelector::new(
454        key_ref.label().to_owned(),
455        Some(mkit_keystore::Algorithm::Ed25519),
456    )
457    .map_err(|error| (format!("key.ed25519_ref: {error}"), exit::CONFIG_ERROR))?;
458    let opener = store.opener().ok_or_else(|| {
459        (
460            format!(
461                "keystore backend `{}` does not support opening keys",
462                key_ref.backend()
463            ),
464            exit::DATAERR,
465        )
466    })?;
467    let signer = opener.open(&selector).map_err(|error| match error {
468        mkit_keystore::Error::KeyNotFound(_) => (
469            format!(
470                "missing keystore signing key for algorithm ed25519 — run `mkit key generate --backend {} --algorithm ed25519 --label <label>` first, or set `signer = legacy` and use `mkit keygen`: {error}",
471                key_ref.backend()
472            ),
473            exit::NOINPUT,
474        ),
475        other => (
476            format!("keystore signing key for algorithm ed25519: {other}"),
477            exit::DATAERR,
478        ),
479    })?;
480    Ok(CommitSigner::Keystore(signer))
481}
482
483/// Resolve the commit that `--amend` will replace.
484///
485/// Returns the decoded HEAD [`Commit`]. The new amended commit reuses
486/// this commit's parents and (absent `-m`) its message. Errors when
487/// HEAD has no commit yet (nothing to amend) or when HEAD does not
488/// resolve to a `Commit` object.
489fn resolve_amend_target(
490    mkit_dir: &std::path::Path,
491    store: &ObjectStore,
492) -> Result<Commit, (String, u8)> {
493    let head = refs::resolve_head(mkit_dir)
494        .map_err(|e| (format!("read HEAD: {e}"), exit::DATAERR))?
495        .ok_or_else(|| {
496            (
497                "nothing to amend: HEAD has no commit yet".to_owned(),
498                exit::USAGE,
499            )
500        })?;
501    match store.read_object(&head) {
502        Ok(Object::Commit(c)) => Ok(c),
503        Ok(_) => Err((
504            format!(
505                "cannot amend: HEAD {} is not a commit",
506                format::hex_hash(&head)
507            ),
508            exit::DATAERR,
509        )),
510        Err(e) => Err((
511            format!("read HEAD commit {}: {e}", format::hex_hash(&head)),
512            exit::DATAERR,
513        )),
514    }
515}
516
517/// Advance the branch pointed to by HEAD (or HEAD itself, if detached)
518/// to `commit_hash`.
519///
520/// Routes through [`super::write_ref_recording_history`] so a build
521/// with `--features history-mmr` records every advance in the branch's
522/// journaled MMR under the repo's `refs-history.lock`. Detached HEAD
523/// advances bypass the journal: per-branch history is keyed on a
524/// branch name and a detached HEAD has none.
525fn advance_head(
526    mkit_dir: &std::path::Path,
527    commit_hash: &mkit_core::hash::Hash,
528) -> Result<(), (String, u8)> {
529    let head = refs::read_head(mkit_dir).map_err(|e| (format!("read HEAD: {e}"), exit::DATAERR))?;
530    match head {
531        Head::Branch(name) => super::write_ref_recording_history(
532            mkit_dir,
533            &name,
534            refs::RefWriteCondition::Any,
535            commit_hash,
536        )
537        .map_err(|e| (format!("write ref: {e}"), exit::CANTCREAT)),
538        Head::Detached(_) => refs::write_head_detached(mkit_dir, commit_hash)
539            .map_err(|e| (format!("update HEAD: {e}"), exit::CANTCREAT)),
540    }
541}
542
543/// Resolve the commit author. See [`run`] for precedence order.
544///
545/// Exposed to sibling commands (`cherry_pick`, `merge`) so they apply
546/// the same precedence as `commit`: `--author` flag (if any) → user-
547/// scoped `user.identity` config → signer pubkey fallback. They pass
548/// `None` for `author_flag` because they don't accept that flag.
549pub(super) fn resolve_author(
550    author_flag: Option<&str>,
551    cfg_user_identity: &str,
552    signer_public: &[u8; 32],
553) -> Result<Identity, String> {
554    if let Some(spec) = author_flag {
555        return parse_author_spec(spec);
556    }
557    if !cfg_user_identity.is_empty() {
558        return decode_user_identity_hex(cfg_user_identity);
559    }
560    Ok(Identity::ed25519(*signer_public))
561}
562
563/// Parse a `--author` flag value.
564///
565/// Accepted forms:
566/// * `ed25519:<64-char hex>` — 32-byte Ed25519 public key.
567/// * `did:key:<multibase>` — a `did:key` whose multibase payload (the part
568///   after `did:key:`, e.g. `z6Mk…`) is stored verbatim as the DID payload.
569///   It must be a non-empty printable-ASCII multibase string (validated via
570///   `Identity::is_valid`), matching the on-disk `DidKey` invariant.
571/// * `opaque:<bytes>` — raw UTF-8 bytes, stored as-is.
572fn parse_author_spec(spec: &str) -> Result<Identity, String> {
573    if let Some(hex) = spec.strip_prefix("ed25519:") {
574        let bytes = hex_decode(hex).ok_or_else(|| "ed25519:<hex> invalid hex".to_string())?;
575        if bytes.len() != 32 {
576            return Err("ed25519:<hex> must decode to 32 bytes".to_string());
577        }
578        let mut arr = [0u8; 32];
579        arr.copy_from_slice(&bytes);
580        return Ok(Identity::ed25519(arr));
581    }
582    if let Some(payload) = spec.strip_prefix("did:key:") {
583        // Store the multibase payload verbatim (the `did:key:` scheme prefix
584        // is stripped). A real did:key is base58btc (`z…`); the on-disk
585        // invariant only requires a non-empty printable-ASCII multibase
586        // string, so validate through `is_valid` rather than hex-decoding.
587        let id = Identity {
588            kind: IdentityKind::DidKey,
589            bytes: payload.as_bytes().to_vec(),
590        };
591        if !id.is_valid() {
592            return Err(
593                "did:key:<multibase> must be a non-empty printable-ASCII multibase string \
594                 (e.g. did:key:z6Mk…)"
595                    .to_string(),
596            );
597        }
598        return Ok(id);
599    }
600    if let Some(raw) = spec.strip_prefix("opaque:") {
601        if raw.is_empty() {
602            return Err("opaque:<bytes> must not be empty".to_string());
603        }
604        return Ok(Identity::opaque(raw.as_bytes().to_vec()));
605    }
606    Err(format!(
607        "unknown identity spec '{spec}' — expected ed25519:<hex>, did:key:<hex>, or opaque:<bytes>"
608    ))
609}
610
611/// Decode a `user.identity` config string into an [`Identity`]. The
612/// config file stores the canonical `[kind:u8][len:u16 LE][bytes]`
613/// form (see `config::expand_user_identity`), so we invert that here.
614fn decode_user_identity_hex(hex: &str) -> Result<Identity, String> {
615    let bytes =
616        hex_decode(hex).ok_or_else(|| "user.identity: not a lowercase hex string".to_string())?;
617    if bytes.len() < 3 {
618        return Err("user.identity: too short (kind + len prefix missing)".to_string());
619    }
620    let kind_byte = bytes[0];
621    let declared_len = u16::from(bytes[1]) | (u16::from(bytes[2]) << 8);
622    if bytes.len() != usize::from(declared_len) + 3 {
623        return Err("user.identity: declared length does not match payload".to_string());
624    }
625    let payload = bytes[3..].to_vec();
626    let kind = match kind_byte {
627        0x01 => IdentityKind::Ed25519,
628        0x02 => IdentityKind::DidKey,
629        // 0x03 (mid) shares the Opaque variant — upstream compat.
630        0x03 | 0x04 => IdentityKind::Opaque,
631        other => return Err(format!("user.identity: unknown kind byte {other:#04x}")),
632    };
633    if kind == IdentityKind::Ed25519 && payload.len() != 32 {
634        return Err("user.identity: ed25519 payload must be exactly 32 bytes".to_string());
635    }
636    Ok(Identity {
637        kind,
638        bytes: payload,
639    })
640}
641
642fn hex_decode(s: &str) -> Option<Vec<u8>> {
643    if !s.len().is_multiple_of(2) {
644        return None;
645    }
646    let mut out = Vec::with_capacity(s.len() / 2);
647    let b = s.as_bytes();
648    let mut i = 0;
649    while i < b.len() {
650        let hi = nibble(b[i])?;
651        let lo = nibble(b[i + 1])?;
652        out.push((hi << 4) | lo);
653        i += 2;
654    }
655    Some(out)
656}
657
658fn nibble(c: u8) -> Option<u8> {
659    Some(match c {
660        b'0'..=b'9' => c - b'0',
661        b'a'..=b'f' => 10 + c - b'a',
662        b'A'..=b'F' => 10 + c - b'A',
663        _ => return None,
664    })
665}
666
667fn emit_err(msg: &str, code: u8) -> u8 {
668    let mut stderr = std::io::stderr().lock();
669    let _ = writeln!(stderr, "error: {msg}");
670    code
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use mkit_keystore::Keystore;
677
678    #[test]
679    fn parse_author_ed25519_roundtrips() {
680        let hex = "11".repeat(32);
681        let spec = format!("ed25519:{hex}");
682        let id = parse_author_spec(&spec).unwrap();
683        assert_eq!(id.kind, IdentityKind::Ed25519);
684        assert_eq!(id.bytes.len(), 32);
685        assert!(id.bytes.iter().all(|&b| b == 0x11));
686    }
687
688    #[test]
689    fn parse_author_rejects_bad_ed25519() {
690        assert!(parse_author_spec("ed25519:short").is_err());
691        assert!(parse_author_spec("ed25519:zzzzz").is_err());
692    }
693
694    #[test]
695    fn parse_author_did_key_stores_multibase_payload() {
696        // The multibase payload after `did:key:` is stored verbatim as ASCII.
697        let id = parse_author_spec("did:key:z6MkExample").unwrap();
698        assert_eq!(id.kind, IdentityKind::DidKey);
699        assert_eq!(id.bytes, b"z6MkExample");
700        assert!(id.is_valid());
701    }
702
703    #[test]
704    fn parse_author_did_key_rejects_non_multibase() {
705        // Empty payload and non-printable/whitespace payloads are rejected
706        // (consistent with the on-disk DidKey invariant).
707        assert!(parse_author_spec("did:key:").is_err());
708        assert!(parse_author_spec("did:key:has space").is_err());
709    }
710
711    #[test]
712    fn parse_author_opaque_takes_raw_bytes() {
713        let id = parse_author_spec("opaque:hello world").unwrap();
714        assert_eq!(id.kind, IdentityKind::Opaque);
715        assert_eq!(id.bytes, b"hello world");
716    }
717
718    #[test]
719    fn parse_author_rejects_unknown_prefix() {
720        assert!(parse_author_spec("foo:bar").is_err());
721        assert!(parse_author_spec("").is_err());
722    }
723
724    #[test]
725    fn decode_user_identity_ed25519_roundtrip() {
726        // Mirror expand_user_identity("ed25519:<hex>") output.
727        // 0x01 + len(32=0x20,0x00) + 32 bytes of 0xAB.
728        let mut hex = String::from("012000");
729        hex.push_str(&"ab".repeat(32));
730        let id = decode_user_identity_hex(&hex).unwrap();
731        assert_eq!(id.kind, IdentityKind::Ed25519);
732        assert_eq!(id.bytes.len(), 32);
733    }
734
735    #[test]
736    fn decode_user_identity_rejects_length_mismatch() {
737        let hex = "011000aabbcc"; // declares 16 bytes, provides 3
738        assert!(decode_user_identity_hex(hex).is_err());
739    }
740
741    #[test]
742    fn resolve_author_prefers_flag_over_config() {
743        let kp = KeyPair::generate().unwrap();
744        let hex = "22".repeat(32);
745        let spec = format!("ed25519:{hex}");
746        // Populate config with a DIFFERENT identity to verify flag wins.
747        let cfg_hex = {
748            let mut s = String::from("012000");
749            s.push_str(&"33".repeat(32));
750            s
751        };
752        let id = resolve_author(Some(&spec), &cfg_hex, &kp.public.0).unwrap();
753        assert!(id.bytes.iter().all(|&b| b == 0x22));
754    }
755
756    #[test]
757    fn resolve_author_uses_config_when_no_flag() {
758        let kp = KeyPair::generate().unwrap();
759        let mut cfg_hex = String::from("012000");
760        cfg_hex.push_str(&"44".repeat(32));
761        let id = resolve_author(None, &cfg_hex, &kp.public.0).unwrap();
762        assert_eq!(id.kind, IdentityKind::Ed25519);
763        assert!(id.bytes.iter().all(|&b| b == 0x44));
764    }
765
766    #[test]
767    fn resolve_author_falls_back_to_pubkey() {
768        let kp = KeyPair::generate().unwrap();
769        let id = resolve_author(None, "", &kp.public.0).unwrap();
770        assert_eq!(id.kind, IdentityKind::Ed25519);
771        assert_eq!(id.bytes, kp.public.0.to_vec());
772    }
773
774    #[test]
775    fn keystore_commit_signature_matches_legacy_keypair_signature() {
776        let seed = [0x5a; 32];
777        let kp = KeyPair::from_seed(seed);
778        let store_root = tempfile::tempdir().unwrap();
779        let store = mkit_keystore::SoftwareRawKeystore::with_root(store_root.path().join("keys"));
780        store
781            .importer()
782            .unwrap()
783            .import(
784                &mkit_keystore::KeyLabel::new("committer").unwrap(),
785                mkit_keystore::SecretKey::new(mkit_keystore::Algorithm::Ed25519, seed),
786                mkit_keystore::KeyAttrs::default(),
787                mkit_keystore::ImportOptions::default(),
788            )
789            .unwrap();
790        let selector =
791            mkit_keystore::KeySelector::new("committer", Some(mkit_keystore::Algorithm::Ed25519))
792                .unwrap();
793        let mut signer = CommitSigner::Keystore(store.opener().unwrap().open(&selector).unwrap());
794        let signer_public = signer.public_key().unwrap();
795        let commit = Commit::new_unannotated(
796            [1; 32],
797            vec![[2; 32]],
798            Identity::ed25519(signer_public),
799            signer_public,
800            b"same commit".to_vec(),
801            123,
802            [0; 64],
803        );
804
805        let keystore_sig = signer.sign_commit(&commit).unwrap();
806        let legacy_sig = sign::sign_commit(&commit, &kp).unwrap().0;
807        assert_eq!(keystore_sig, legacy_sig);
808    }
809}