use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use chrono::{Duration, Utc};
use clap::Args;
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
use serde::Serialize;
use serde_json::json;
use crate::cmd::restore::intent::{
derive_operator_principal_id, RESTORE_INTENT_KIND, RESTORE_INTENT_SCHEMA_VERSION,
};
use crate::cmd::restore::production::deployment_id_for;
use crate::exit::Exit;
use crate::output::{self, Envelope};
use crate::paths::DataLayout;
pub const MAX_VALIDITY_WINDOW_HOURS: i64 = 24;
pub const DEFAULT_VALIDITY_WINDOW_HOURS: i64 = 1;
pub const NOT_BEFORE_SKEW_SECONDS: i64 = 5 * 60;
pub const INTENT_BUILD_PUBKEY_MALFORMED_INVARIANT: &str = "restore.intent.build.pubkey_malformed";
pub const INTENT_BUILD_STAGED_PAIRS_MISMATCH_INVARIANT: &str =
"restore.intent.build.staged_pairs.mismatch";
pub const INTENT_BUILD_VALIDITY_WINDOW_INVALID_INVARIANT: &str =
"restore.intent.build.validity_window.invalid";
pub const INTENT_BUILD_SIGNING_KEY_MALFORMED_INVARIANT: &str =
"restore.intent.build.signing_key.malformed";
pub const INTENT_BUILD_SIGN_FLAG_INCONSISTENT_INVARIANT: &str =
"restore.intent.build.sign_flag.inconsistent";
pub const INTENT_BUILD_KEY_MATERIAL_DISAGREE_INVARIANT: &str =
"restore.intent.build.key_material.disagree";
pub const INTENT_BUILD_OUTPUT_ALREADY_EXISTS_INVARIANT: &str =
"restore.intent.build.output.already_exists";
#[derive(Debug, Args)]
pub struct BuildArgs {
#[arg(long, value_name = "HEX")]
pub operator_pubkey_hex: Option<String>,
#[arg(long, value_name = "DEPLOYMENT_ID")]
pub deployment_id: Option<String>,
#[arg(long, value_name = "PATH")]
pub backup_manifest: PathBuf,
#[arg(long = "staged-artifact-path", value_name = "PATH")]
pub staged_artifact_paths: Vec<PathBuf>,
#[arg(long = "staged-artifact-digest-blake3", value_name = "BLAKE3_HEX")]
pub staged_artifact_digests_blake3: Vec<String>,
#[arg(long, value_name = "HOURS", default_value_t = DEFAULT_VALIDITY_WINDOW_HOURS)]
pub expires_in_hours: i64,
#[arg(long)]
pub sign: bool,
#[arg(long, value_name = "BASE64_OR_DASH")]
pub signing_key_b64: Option<String>,
#[arg(long, value_name = "PATH")]
pub output: PathBuf,
}
pub fn run(args: BuildArgs) -> Exit {
let json = output::json_enabled();
if args.expires_in_hours < 1 || args.expires_in_hours > MAX_VALIDITY_WINDOW_HOURS {
let detail = format!(
"validity window must be in [1, {MAX_VALIDITY_WINDOW_HOURS}] hours; got {}",
args.expires_in_hours
);
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_VALIDITY_WINDOW_INVALID_INVARIANT} {detail}"
);
return finish(
Exit::Usage,
&detail,
Some(INTENT_BUILD_VALIDITY_WINDOW_INVALID_INVARIANT),
&args.output,
None,
json,
);
}
if args.staged_artifact_paths.len() != args.staged_artifact_digests_blake3.len() {
let detail = format!(
"--staged-artifact-path count ({}) must equal --staged-artifact-digest-blake3 count ({})",
args.staged_artifact_paths.len(),
args.staged_artifact_digests_blake3.len(),
);
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_STAGED_PAIRS_MISMATCH_INVARIANT} {detail}"
);
return finish(
Exit::Usage,
&detail,
Some(INTENT_BUILD_STAGED_PAIRS_MISMATCH_INVARIANT),
&args.output,
None,
json,
);
}
if !args.staged_artifact_paths.is_empty() && args.staged_artifact_paths.len() != 2 {
let detail = format!(
"exactly 0 or 2 --staged-artifact-path/--staged-artifact-digest-blake3 pairs required (first=sqlite, second=jsonl); got {} pairs",
args.staged_artifact_paths.len()
);
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_STAGED_PAIRS_MISMATCH_INVARIANT} {detail}"
);
return finish(
Exit::Usage,
&detail,
Some(INTENT_BUILD_STAGED_PAIRS_MISMATCH_INVARIANT),
&args.output,
None,
json,
);
}
if args.sign != args.signing_key_b64.is_some() {
let detail = "--sign and --signing-key-b64 must be supplied together".to_string();
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_SIGN_FLAG_INCONSISTENT_INVARIANT} {detail}"
);
return finish(
Exit::Usage,
&detail,
Some(INTENT_BUILD_SIGN_FLAG_INCONSISTENT_INVARIANT),
&args.output,
None,
json,
);
}
if args.output.exists() {
let detail = format!(
"refusing to overwrite existing --output `{}`",
args.output.display()
);
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_OUTPUT_ALREADY_EXISTS_INVARIANT} {detail}"
);
return finish(
Exit::PreconditionUnmet,
&detail,
Some(INTENT_BUILD_OUTPUT_ALREADY_EXISTS_INVARIANT),
&args.output,
None,
json,
);
}
let signing_key = match args.signing_key_b64.as_deref() {
Some(value) => match resolve_signing_key(value) {
Ok(key) => Some(key),
Err(detail) => {
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_SIGNING_KEY_MALFORMED_INVARIANT} {detail}"
);
return finish(
Exit::PreconditionUnmet,
&detail,
Some(INTENT_BUILD_SIGNING_KEY_MALFORMED_INVARIANT),
&args.output,
None,
json,
);
}
},
None => None,
};
let verifying_key = match (&args.operator_pubkey_hex, &signing_key) {
(Some(hex), Some(sk)) => {
let supplied = match parse_pubkey_hex(hex) {
Ok(key) => key,
Err(detail) => {
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_PUBKEY_MALFORMED_INVARIANT} {detail}"
);
return finish(
Exit::PreconditionUnmet,
&detail,
Some(INTENT_BUILD_PUBKEY_MALFORMED_INVARIANT),
&args.output,
None,
json,
);
}
};
let derived = sk.verifying_key();
if supplied.to_bytes() != derived.to_bytes() {
let detail = "--operator-pubkey-hex does not match the verifying key derived from --signing-key-b64".to_string();
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_KEY_MATERIAL_DISAGREE_INVARIANT} {detail}"
);
return finish(
Exit::PreconditionUnmet,
&detail,
Some(INTENT_BUILD_KEY_MATERIAL_DISAGREE_INVARIANT),
&args.output,
None,
json,
);
}
supplied
}
(Some(hex), None) => match parse_pubkey_hex(hex) {
Ok(key) => key,
Err(detail) => {
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_PUBKEY_MALFORMED_INVARIANT} {detail}"
);
return finish(
Exit::PreconditionUnmet,
&detail,
Some(INTENT_BUILD_PUBKEY_MALFORMED_INVARIANT),
&args.output,
None,
json,
);
}
},
(None, Some(sk)) => sk.verifying_key(),
(None, None) => {
let detail =
"either --operator-pubkey-hex or --sign --signing-key-b64 must be supplied"
.to_string();
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_PUBKEY_MALFORMED_INVARIANT} {detail}"
);
return finish(
Exit::Usage,
&detail,
Some(INTENT_BUILD_PUBKEY_MALFORMED_INVARIANT),
&args.output,
None,
json,
);
}
};
let operator_principal_id = derive_operator_principal_id(&verifying_key.to_bytes());
let layout = match DataLayout::resolve(None, None) {
Ok(layout) => layout,
Err(exit) => {
let detail = "failed to resolve data layout".to_string();
eprintln!("cortex restore intent build: {detail}");
return finish(exit, &detail, None, &args.output, None, json);
}
};
let deployment_id = args
.deployment_id
.clone()
.unwrap_or_else(|| deployment_id_for(&layout));
let manifest_bytes = match fs::read(&args.backup_manifest) {
Ok(bytes) => bytes,
Err(err) => {
let detail = format!(
"cannot read --backup-manifest `{}`: {err}",
args.backup_manifest.display()
);
eprintln!("cortex restore intent build: {detail}");
return finish(
Exit::PreconditionUnmet,
&detail,
None,
&args.output,
None,
json,
);
}
};
let manifest_b3 = format!("blake3:{}", blake3::hash(&manifest_bytes).to_hex());
let (staged_sqlite_b3, staged_jsonl_b3) = if args.staged_artifact_paths.is_empty() {
match extract_staged_digests_from_manifest(&manifest_bytes) {
Some(pair) => pair,
None => {
let detail = "no --staged-artifact-path supplied and --backup-manifest does not declare sqlite_store_blake3 + jsonl_mirror_blake3".to_string();
eprintln!("cortex restore intent build: {detail}");
return finish(
Exit::PreconditionUnmet,
&detail,
None,
&args.output,
None,
json,
);
}
}
} else {
(
args.staged_artifact_digests_blake3[0].clone(),
args.staged_artifact_digests_blake3[1].clone(),
)
};
let active_db_path = canonicalize_string(&layout.db_path);
let active_event_log_path = canonicalize_string(&layout.event_log_path);
let now = Utc::now();
let not_before = now - Duration::seconds(NOT_BEFORE_SKEW_SECONDS);
let not_after = now + Duration::hours(args.expires_in_hours);
let payload = RestoreIntentPayloadView {
active_db_path: active_db_path.clone(),
active_event_log_path: active_event_log_path.clone(),
backup_manifest_blake3: manifest_b3.clone(),
deployment_id: deployment_id.clone(),
kind: RESTORE_INTENT_KIND.to_string(),
not_after: not_after.to_rfc3339(),
not_before: not_before.to_rfc3339(),
operator_principal_id: operator_principal_id.clone(),
p_n_schema_version: RESTORE_INTENT_SCHEMA_VERSION,
schema_version: RESTORE_INTENT_SCHEMA_VERSION,
staged_jsonl_blake3: staged_jsonl_b3.clone(),
staged_sqlite_blake3: staged_sqlite_b3.clone(),
};
let canonical_bytes = match serde_json::to_vec(&payload) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!("cortex restore intent build: failed to serialize payload: {err}");
return finish(
Exit::Internal,
&err.to_string(),
None,
&args.output,
None,
json,
);
}
};
if let Err(err) = fs::write(&args.output, &canonical_bytes) {
let detail = format!("cannot write --output `{}`: {err}", args.output.display());
eprintln!("cortex restore intent build: {detail}");
return finish(Exit::Internal, &detail, None, &args.output, None, json);
}
let mut signature_path: Option<PathBuf> = None;
if let Some(sk) = signing_key.as_ref() {
let sig_path = sibling_sig_path(&args.output);
if sig_path.exists() {
let detail = format!(
"refusing to overwrite existing signature `{}`",
sig_path.display()
);
eprintln!(
"cortex restore intent build: invariant={INTENT_BUILD_OUTPUT_ALREADY_EXISTS_INVARIANT} {detail}"
);
let _ = fs::remove_file(&args.output);
return finish(
Exit::PreconditionUnmet,
&detail,
Some(INTENT_BUILD_OUTPUT_ALREADY_EXISTS_INVARIANT),
&args.output,
Some(&sig_path),
json,
);
}
let signature = sk.sign(&canonical_bytes);
if let Err(err) = fs::write(&sig_path, signature.to_bytes()) {
let detail = format!("cannot write signature `{}`: {err}", sig_path.display());
eprintln!("cortex restore intent build: {detail}");
let _ = fs::remove_file(&args.output);
return finish(Exit::Internal, &detail, None, &args.output, None, json);
}
signature_path = Some(sig_path);
}
if !json {
println!(
"cortex restore intent build: minted `{}` (operator_principal_id={operator_principal_id})",
args.output.display()
);
if let Some(sig_path) = signature_path.as_ref() {
println!(
"cortex restore intent build: signed -> `{}`",
sig_path.display()
);
}
}
finish_ok(
&payload,
&canonical_bytes,
verifying_key,
&args.output,
signature_path.as_deref(),
json,
)
}
#[derive(Debug, Serialize)]
struct RestoreIntentPayloadView {
active_db_path: String,
active_event_log_path: String,
backup_manifest_blake3: String,
deployment_id: String,
kind: String,
not_after: String,
not_before: String,
operator_principal_id: String,
p_n_schema_version: u16,
schema_version: u16,
staged_jsonl_blake3: String,
staged_sqlite_blake3: String,
}
fn sibling_sig_path(intent_path: &Path) -> PathBuf {
let mut path = intent_path.to_path_buf();
let stem = path
.file_stem()
.map(|s| s.to_owned())
.unwrap_or_else(|| std::ffi::OsString::from("RESTORE_INTENT"));
let mut sig_name = stem;
sig_name.push(".sig");
path.set_file_name(sig_name);
path
}
fn canonicalize_string(path: &Path) -> String {
path.canonicalize()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string_lossy().into_owned())
}
fn resolve_signing_key(value: &str) -> Result<SigningKey, String> {
let b64 = if value == "-" {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.map_err(|err| format!("cannot read --signing-key-b64 from stdin: {err}"))?;
buf.trim().to_string()
} else {
value.trim().to_string()
};
let raw = STANDARD
.decode(b64.as_bytes())
.map_err(|err| format!("--signing-key-b64 is not valid base64: {err}"))?;
let seed: [u8; 32] = raw.as_slice().try_into().map_err(|_| {
format!(
"--signing-key-b64 must decode to 32 bytes (Ed25519 seed); got {}",
raw.len()
)
})?;
Ok(SigningKey::from_bytes(&seed))
}
fn parse_pubkey_hex(hex_str: &str) -> Result<VerifyingKey, String> {
let hex_str = hex_str.trim();
if hex_str.len() != 64 {
return Err(format!(
"--operator-pubkey-hex must be exactly 64 hex chars (32 bytes); got {}",
hex_str.len()
));
}
let mut bytes = [0u8; 32];
for (i, byte) in bytes.iter_mut().enumerate() {
let chunk = &hex_str[2 * i..2 * i + 2];
*byte = u8::from_str_radix(chunk, 16)
.map_err(|err| format!("--operator-pubkey-hex chunk `{chunk}` is not hex: {err}"))?;
}
VerifyingKey::from_bytes(&bytes)
.map_err(|err| format!("--operator-pubkey-hex is not a valid Ed25519 verifying key: {err}"))
}
fn extract_staged_digests_from_manifest(bytes: &[u8]) -> Option<(String, String)> {
let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
let sqlite = value.get("sqlite_store_blake3")?.as_str()?.to_string();
let jsonl = value.get("jsonl_mirror_blake3")?.as_str()?.to_string();
Some((sqlite, jsonl))
}
fn finish(
exit: Exit,
detail: &str,
invariant: Option<&'static str>,
output: &Path,
signature: Option<&Path>,
json: bool,
) -> Exit {
if !json {
return exit;
}
let report = json!({
"command": "cortex.restore.intent.build",
"detail": detail,
"invariant": invariant,
"output_path": output.display().to_string(),
"signature_path": signature.map(|p| p.display().to_string()),
"persisted": false,
});
let envelope = Envelope::new("cortex.restore.intent.build", exit, report);
output::emit(&envelope, exit)
}
fn finish_ok(
payload: &RestoreIntentPayloadView,
canonical_bytes: &[u8],
verifying_key: VerifyingKey,
output: &Path,
signature: Option<&Path>,
json: bool,
) -> Exit {
if !json {
return Exit::Ok;
}
let pubkey_hex = lowercase_hex(verifying_key.as_bytes());
let canonical_blake3 = format!("blake3:{}", blake3::hash(canonical_bytes).to_hex());
let report = json!({
"command": "cortex.restore.intent.build",
"output_path": output.display().to_string(),
"signature_path": signature.map(|p| p.display().to_string()),
"canonical_bytes_len": canonical_bytes.len(),
"canonical_blake3": canonical_blake3,
"operator_pubkey_hex": pubkey_hex,
"payload": payload,
"persisted": true,
});
let envelope = Envelope::new("cortex.restore.intent.build", Exit::Ok, report);
output::emit(&envelope, Exit::Ok)
}
fn lowercase_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::Verifier;
fn write_minimal_manifest(dir: &Path) -> PathBuf {
let path = dir.join("BACKUP_MANIFEST");
let manifest = json!({
"kind": "cortex_pre_v2_backup",
"schema_version": 1,
"sqlite_store": "cortex.db",
"jsonl_mirror": "events.jsonl",
"tool_version": "test",
"backup_timestamp": "2026-05-12T00:00:00Z",
"sqlite_store_size_bytes": 0,
"sqlite_store_blake3": "blake3:aa",
"jsonl_mirror_size_bytes": 0,
"jsonl_mirror_blake3": "blake3:bb",
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
path
}
fn seed_to_b64(seed: [u8; 32]) -> String {
STANDARD.encode(seed)
}
fn pubkey_hex_from_seed(seed: [u8; 32]) -> String {
let sk = SigningKey::from_bytes(&seed);
lowercase_hex(sk.verifying_key().as_bytes())
}
#[test]
fn build_unsigned_minted_payload_round_trips_through_serde() {
let dir = tempfile::tempdir().unwrap();
let manifest = write_minimal_manifest(dir.path());
let output = dir.path().join("RESTORE_INTENT.json");
let pubkey_hex = pubkey_hex_from_seed([3u8; 32]);
let args = BuildArgs {
operator_pubkey_hex: Some(pubkey_hex.clone()),
deployment_id: Some("deployment:test".to_string()),
backup_manifest: manifest,
staged_artifact_paths: Vec::new(),
staged_artifact_digests_blake3: Vec::new(),
expires_in_hours: 1,
sign: false,
signing_key_b64: None,
output: output.clone(),
};
let exit = run(args);
assert_eq!(exit, Exit::Ok);
let bytes = fs::read(&output).unwrap();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["kind"], "cortex_restore_intent");
assert_eq!(value["deployment_id"], "deployment:test");
let principal = value["operator_principal_id"].as_str().unwrap();
assert!(
principal.starts_with("operator:"),
"principal must use the operator: prefix; got {principal}"
);
let mut pk_bytes = [0u8; 32];
for (i, byte) in pk_bytes.iter_mut().enumerate() {
*byte = u8::from_str_radix(&pubkey_hex[2 * i..2 * i + 2], 16).unwrap();
}
assert_eq!(principal, derive_operator_principal_id(&pk_bytes));
}
#[test]
fn build_with_sign_produces_round_trippable_signature() {
let dir = tempfile::tempdir().unwrap();
let manifest = write_minimal_manifest(dir.path());
let output = dir.path().join("RESTORE_INTENT.json");
let seed = [11u8; 32];
let args = BuildArgs {
operator_pubkey_hex: None,
deployment_id: Some("deployment:test".to_string()),
backup_manifest: manifest,
staged_artifact_paths: Vec::new(),
staged_artifact_digests_blake3: Vec::new(),
expires_in_hours: 1,
sign: true,
signing_key_b64: Some(seed_to_b64(seed)),
output: output.clone(),
};
let exit = run(args);
assert_eq!(exit, Exit::Ok);
let canonical_bytes = fs::read(&output).unwrap();
let sig_path = sibling_sig_path(&output);
assert!(sig_path.exists(), "expected sibling .sig at {sig_path:?}");
let sig_bytes = fs::read(&sig_path).unwrap();
assert_eq!(sig_bytes.len(), 64, "Ed25519 signature must be 64 bytes");
let sk = SigningKey::from_bytes(&seed);
let vk = sk.verifying_key();
let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().unwrap();
let signature = ed25519_dalek::Signature::from_bytes(&sig_array);
vk.verify(&canonical_bytes, &signature)
.expect("signature must verify against the derived public key");
let value: serde_json::Value = serde_json::from_slice(&canonical_bytes).unwrap();
let principal = value["operator_principal_id"].as_str().unwrap();
assert_eq!(principal, derive_operator_principal_id(&vk.to_bytes()));
}
#[test]
fn build_refuses_when_validity_window_exceeds_ceiling() {
let dir = tempfile::tempdir().unwrap();
let manifest = write_minimal_manifest(dir.path());
let output = dir.path().join("RESTORE_INTENT.json");
let args = BuildArgs {
operator_pubkey_hex: Some(pubkey_hex_from_seed([5u8; 32])),
deployment_id: Some("deployment:test".to_string()),
backup_manifest: manifest,
staged_artifact_paths: Vec::new(),
staged_artifact_digests_blake3: Vec::new(),
expires_in_hours: 48,
sign: false,
signing_key_b64: None,
output: output.clone(),
};
let exit = run(args);
assert_eq!(exit, Exit::Usage);
assert!(
!output.exists(),
"no output must be written when the validity window is refused"
);
}
#[test]
fn build_refuses_when_signing_key_and_pubkey_disagree() {
let dir = tempfile::tempdir().unwrap();
let manifest = write_minimal_manifest(dir.path());
let output = dir.path().join("RESTORE_INTENT.json");
let args = BuildArgs {
operator_pubkey_hex: Some(pubkey_hex_from_seed([7u8; 32])),
deployment_id: Some("deployment:test".to_string()),
backup_manifest: manifest,
staged_artifact_paths: Vec::new(),
staged_artifact_digests_blake3: Vec::new(),
expires_in_hours: 1,
sign: true,
signing_key_b64: Some(seed_to_b64([8u8; 32])),
output: output.clone(),
};
let exit = run(args);
assert_eq!(exit, Exit::PreconditionUnmet);
assert!(!output.exists(), "no output on key-material disagreement");
}
#[test]
fn build_refuses_when_output_already_exists() {
let dir = tempfile::tempdir().unwrap();
let manifest = write_minimal_manifest(dir.path());
let output = dir.path().join("RESTORE_INTENT.json");
fs::write(&output, b"pre-existing").unwrap();
let args = BuildArgs {
operator_pubkey_hex: Some(pubkey_hex_from_seed([2u8; 32])),
deployment_id: Some("deployment:test".to_string()),
backup_manifest: manifest,
staged_artifact_paths: Vec::new(),
staged_artifact_digests_blake3: Vec::new(),
expires_in_hours: 1,
sign: false,
signing_key_b64: None,
output: output.clone(),
};
let exit = run(args);
assert_eq!(exit, Exit::PreconditionUnmet);
let after = fs::read(&output).unwrap();
assert_eq!(after, b"pre-existing");
}
#[test]
fn build_refuses_when_staged_pairs_unbalanced() {
let dir = tempfile::tempdir().unwrap();
let manifest = write_minimal_manifest(dir.path());
let output = dir.path().join("RESTORE_INTENT.json");
let args = BuildArgs {
operator_pubkey_hex: Some(pubkey_hex_from_seed([1u8; 32])),
deployment_id: Some("deployment:test".to_string()),
backup_manifest: manifest,
staged_artifact_paths: vec![dir.path().join("staged.db")],
staged_artifact_digests_blake3: Vec::new(),
expires_in_hours: 1,
sign: false,
signing_key_b64: None,
output: output.clone(),
};
let exit = run(args);
assert_eq!(exit, Exit::Usage);
assert!(!output.exists(), "no output when staged pairs unbalanced");
}
#[test]
fn parse_pubkey_hex_rejects_wrong_length() {
let err = parse_pubkey_hex("aabb").unwrap_err();
assert!(err.contains("64 hex chars"), "got: {err}");
}
}