ms-cli 0.13.1

Companion CLI for ms-codec — engrave-friendly BIP-39 entropy backups (the ms1 format).
//! Integration tests for `ms derive` (read-only: master fingerprint + account xpub).
//!
//! The all-zeros 16-byte entropy (abandon×11-about) is the corpus. Expected
//! values are independently known: master fp `73c5da0a`; bip84 account xpub
//! `xpub6CatWdiZi…` (the same account the toolkit/mk oracles confirm). No
//! secrets (seed/xprv) ever reach stdout.

use std::process::Output;

use assert_cmd::Command;

const ZEROS_HEX: &str = "00000000000000000000000000000000";
const ABANDON: &str =
    "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const MASTER_FP_EN: &str = "73c5da0a";
const MASTER_FP_FR: &str = "7d53dc37";
const BIP84_ACCT_XPUB: &str = "xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V";

fn ms(args: &[&str]) -> Output {
    Command::cargo_bin("ms")
        .unwrap()
        .args(args)
        .output()
        .unwrap()
}
fn out(o: &Output) -> String {
    String::from_utf8(o.stdout.clone()).unwrap()
}
fn err(o: &Output) -> String {
    String::from_utf8(o.stderr.clone()).unwrap()
}
fn code(o: &Output) -> i32 {
    o.status.code().unwrap()
}

/// Build an ms1 string for hex entropy via `ms encode`.
fn ms1_of(hex: &str) -> String {
    let o = ms(&["encode", "--hex", hex]);
    assert!(o.status.success(), "encode: {}", err(&o));
    out(&o).lines().next().unwrap().trim().to_string()
}

#[test]
fn fingerprint_from_ms1() {
    let card = ms1_of(ZEROS_HEX);
    let o = ms(&["derive", &card]);
    assert_eq!(code(&o), 0, "{}", err(&o));
    assert!(out(&o).contains(MASTER_FP_EN), "{}", out(&o));
}

#[test]
fn hex_and_phrase_parity() {
    let from_hex = ms(&["derive", "--hex", ZEROS_HEX]);
    let from_phrase = ms(&["derive", "--phrase", ABANDON]);
    assert!(out(&from_hex).contains(MASTER_FP_EN), "{}", out(&from_hex));
    assert!(
        out(&from_phrase).contains(MASTER_FP_EN),
        "{}",
        out(&from_phrase)
    );
}

#[test]
fn account_xpub_bip84_matches_oracle() {
    let o = ms(&["derive", "--hex", ZEROS_HEX, "--template", "bip84"]);
    assert_eq!(code(&o), 0, "{}", err(&o));
    let s = out(&o);
    assert!(s.contains(BIP84_ACCT_XPUB), "{s}");
    assert!(s.contains("m/84'/0'/0'"), "{s}");
}

#[test]
fn account_index_changes_xpub() {
    let a0 = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--template",
        "bip84",
        "--account",
        "0",
        "--json",
    ]);
    let a1 = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--template",
        "bip84",
        "--account",
        "1",
        "--json",
    ]);
    let v0: serde_json::Value = serde_json::from_str(&out(&a0)).unwrap();
    let v1: serde_json::Value = serde_json::from_str(&out(&a1)).unwrap();
    assert_ne!(v0["account_xpub"], v1["account_xpub"]);
    assert_eq!(v1["account_path"], "m/84'/0'/1'");
}

#[test]
fn no_template_no_account_line() {
    let o = ms(&["derive", "--hex", ZEROS_HEX]);
    let s = out(&o);
    assert!(s.contains(MASTER_FP_EN));
    assert!(!s.contains("account_xpub"), "{s}");
}

#[test]
fn language_is_load_bearing() {
    let en = ms(&["derive", "--hex", ZEROS_HEX, "--language", "english"]);
    let fr = ms(&["derive", "--hex", ZEROS_HEX, "--language", "french"]);
    assert!(out(&en).contains(MASTER_FP_EN));
    assert!(out(&fr).contains(MASTER_FP_FR));
    assert_ne!(MASTER_FP_EN, MASTER_FP_FR);
}

#[test]
fn default_language_annotated() {
    let o = ms(&["derive", "--hex", ZEROS_HEX]);
    assert!(out(&o).contains("DEFAULT"), "stdout: {}", out(&o));
    assert!(err(&o).contains("defaulted"), "stderr: {}", err(&o));
    // explicit language → no DEFAULT
    let ex = ms(&["derive", "--hex", ZEROS_HEX, "--language", "english"]);
    assert!(!out(&ex).contains("DEFAULT"), "{}", out(&ex));
}

#[test]
fn passphrase_changes_fingerprint() {
    let plain = ms(&["derive", "--hex", ZEROS_HEX]);
    let with_pp = ms(&["derive", "--hex", ZEROS_HEX, "--passphrase", "TREZOR"]);
    assert!(out(&plain).contains(MASTER_FP_EN));
    assert!(
        !out(&with_pp).contains(MASTER_FP_EN),
        "passphrase must change fp: {}",
        out(&with_pp)
    );
}

#[test]
fn passphrase_stdin_reads_stdin() {
    let o = Command::cargo_bin("ms")
        .unwrap()
        .args(["derive", "--hex", ZEROS_HEX, "--passphrase-stdin"])
        .write_stdin("TREZOR")
        .output()
        .unwrap();
    assert_eq!(
        o.status.code().unwrap(),
        0,
        "{}",
        String::from_utf8_lossy(&o.stderr)
    );
    assert!(
        !String::from_utf8(o.stdout).unwrap().contains(MASTER_FP_EN),
        "passphrase applied"
    );
}

#[test]
fn passphrase_stdin_preserves_multiword_matches_inline() {
    // C1 regression: a multi-word passphrase via stdin must NOT be whitespace-
    // stripped — it must equal the inline --passphrase result for the same bytes.
    let inline = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--passphrase",
        "a b c",
        "--json",
    ]);
    let from_stdin = Command::cargo_bin("ms")
        .unwrap()
        .args(["derive", "--hex", ZEROS_HEX, "--passphrase-stdin", "--json"])
        .write_stdin("a b c\n")
        .output()
        .unwrap();
    let vi: serde_json::Value = serde_json::from_str(&out(&inline)).unwrap();
    let vs: serde_json::Value =
        serde_json::from_str(&String::from_utf8(from_stdin.stdout).unwrap()).unwrap();
    assert_eq!(
        vi["master_fingerprint"], vs["master_fingerprint"],
        "stdin passphrase must match inline"
    );
    // and differ from the no-passphrase fp (proves it was actually applied).
    assert_ne!(vi["master_fingerprint"], MASTER_FP_EN);
}

#[test]
fn single_stdin_guard() {
    // ms1 from stdin + --passphrase-stdin → BadInput (one stdin).
    let card = ms1_of(ZEROS_HEX);
    let o = Command::cargo_bin("ms")
        .unwrap()
        .args(["derive", "--passphrase-stdin"])
        .write_stdin(card)
        .output()
        .unwrap();
    assert_eq!(
        o.status.code().unwrap(),
        1,
        "{}",
        String::from_utf8_lossy(&o.stderr)
    );
}

#[test]
fn network_testnet_tpub_same_fingerprint() {
    let main = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--template",
        "bip84",
        "--json",
    ]);
    let test = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--template",
        "bip84",
        "--network",
        "testnet",
        "--json",
    ]);
    let vm: serde_json::Value = serde_json::from_str(&out(&main)).unwrap();
    let vt: serde_json::Value = serde_json::from_str(&out(&test)).unwrap();
    assert_eq!(
        vm["master_fingerprint"], vt["master_fingerprint"],
        "fp network-independent"
    );
    assert!(
        vt["account_xpub"].as_str().unwrap().starts_with("tpub"),
        "{}",
        vt["account_xpub"]
    );
    assert_eq!(vt["account_path"], "m/84'/1'/0'");
}

#[test]
fn input_exclusivity() {
    let card = ms1_of(ZEROS_HEX);
    let o = ms(&["derive", &card, "--hex", ZEROS_HEX]); // ms1 + --hex
    assert_eq!(code(&o), 64, "{}", err(&o)); // clap conflict → ms-cli catch-all 64
    assert_ne!(code(&ms(&["derive", "--hex", "zz"])), 0); // bad hex
}

#[test]
fn json_shape() {
    let o = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--template",
        "bip84",
        "--json",
    ]);
    let v: serde_json::Value = serde_json::from_str(&out(&o)).unwrap();
    assert_eq!(v["schema_version"], "1");
    assert_eq!(v["master_fingerprint"], MASTER_FP_EN);
    assert_eq!(v["network"], "mainnet");
    assert_eq!(v["account_xpub"], BIP84_ACCT_XPUB);
    assert_eq!(v["language_defaulted"], true);
    // no-template → account fields omitted (skip_serializing_if)
    let nt = ms(&["derive", "--hex", ZEROS_HEX, "--json"]);
    let vnt: serde_json::Value = serde_json::from_str(&out(&nt)).unwrap();
    assert!(
        vnt.get("account_xpub").is_none(),
        "omitted without --template"
    );
}

#[test]
fn no_secret_on_stdout() {
    // PUBLIC-only boundary: stdout never carries an xprv/tprv or a 64-byte seed.
    let o = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--template",
        "bip84",
        "--network",
        "testnet",
    ]);
    let s = out(&o);
    assert!(!s.contains("xprv"), "{s}");
    assert!(!s.contains("tprv"), "{s}");
}

#[test]
fn inline_secret_argv_advisory() {
    let o = ms(&["derive", "--hex", ZEROS_HEX]);
    assert!(
        err(&o).contains("secret material on argv (--hex)"),
        "{}",
        err(&o)
    );
}

/// Wave-2 ms lane (slug `ms-cli-derive-xpriv-master-not-zeroized`, in-repo
/// leg) — the derived `master`/`acct_xpriv` `Xpriv` values are now confined in
/// a binary-private move-only `ScrubbedXpriv` newtype that byte-scrubs on drop.
/// `master_fingerprint` and `account_xpub` are materialized (`.to_string()`)
/// BEFORE either wrapper drops, so the scrub is output-invisible. This pins the
/// byte-identical-output invariant across the scrub rewire: a regression in the
/// rewire (reading a stale/zeroed value, or moving the materialize past the
/// scrub) surfaces here as a golden mismatch. Anchors on `MASTER_FP_EN` +
/// `BIP84_ACCT_XPUB` (text + `--json`, fingerprint-only + account-xpub paths).
#[test]
fn scrub_rewire_leaves_output_byte_identical() {
    // (a) fingerprint-only (text) — master fp unchanged.
    let fp_text = ms(&["derive", "--hex", ZEROS_HEX, "--language", "english"]);
    assert_eq!(code(&fp_text), 0, "{}", err(&fp_text));
    assert!(
        out(&fp_text).contains(&format!("master_fingerprint:  {MASTER_FP_EN}")),
        "fingerprint-only text output changed by scrub rewire: {}",
        out(&fp_text)
    );

    // (b) fingerprint-only (--json) — exact field value unchanged.
    let fp_json = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--language",
        "english",
        "--json",
    ]);
    let vj: serde_json::Value = serde_json::from_str(&out(&fp_json)).unwrap();
    assert_eq!(vj["master_fingerprint"], MASTER_FP_EN);
    assert!(
        vj.get("account_xpub").is_none(),
        "no account without --template: {}",
        out(&fp_json)
    );

    // (c) account-xpub bip84 (text) — both fp and account xpub unchanged.
    let acct_text = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--language",
        "english",
        "--template",
        "bip84",
    ]);
    assert_eq!(code(&acct_text), 0, "{}", err(&acct_text));
    let at = out(&acct_text);
    assert!(at.contains(MASTER_FP_EN), "account text fp changed: {at}");
    assert!(
        at.contains(BIP84_ACCT_XPUB),
        "account xpub changed by scrub rewire: {at}"
    );

    // (d) account-xpub bip84 (--json) — exact field values unchanged.
    let acct_json = ms(&[
        "derive",
        "--hex",
        ZEROS_HEX,
        "--language",
        "english",
        "--template",
        "bip84",
        "--json",
    ]);
    let va: serde_json::Value = serde_json::from_str(&out(&acct_json)).unwrap();
    assert_eq!(va["master_fingerprint"], MASTER_FP_EN);
    assert_eq!(va["account_xpub"], BIP84_ACCT_XPUB);
    assert_eq!(va["account_path"], "m/84'/0'/0'");
}