use clap::Args;
use std::path::PathBuf;
#[derive(Args)]
pub struct VerifyArgs {
#[arg(long, value_name = "LOG_FILE")]
pub log: PathBuf,
#[arg(long, value_name = "PUBKEY_FILE")]
pub pubkey: PathBuf,
#[arg(long, value_name = "HEX")]
pub merkle_root: Option<String>,
#[arg(long, value_name = "HEX")]
pub predecessor_digest: Option<String>,
}
pub fn run(args: &VerifyArgs) -> i32 {
let kf = match crate::key_file::load_key_file(&args.pubkey) {
Ok(kf) => kf,
Err(e) => {
eprintln!("error: {e}");
return 2;
}
};
let (vk, _kid) = match crate::key_file::load_verifying_key(&kf) {
Ok(v) => v,
Err(e) => {
eprintln!("error: {e}");
return 2;
}
};
const LOG_SIZE_LIMIT: u64 = 512 * 1024 * 1024; match std::fs::metadata(&args.log) {
Ok(meta) if meta.len() > LOG_SIZE_LIMIT => {
eprintln!(
"error: audit log {:?} is too large ({} bytes; limit is {LOG_SIZE_LIMIT} bytes)",
args.log,
meta.len()
);
return 2;
}
Ok(_) => {}
Err(e) => {
eprintln!("error: failed to stat audit log: {e}");
return 2;
}
}
let content = match std::fs::read_to_string(&args.log) {
Ok(c) => c,
Err(e) => {
eprintln!("error: failed to read audit log: {e}");
return 2;
}
};
let verified_count = match invariant_robotics::audit::verify_log(&content, &vk) {
Ok(count) => count,
Err(e) => {
eprintln!("FAIL: {e}");
return 1;
}
};
if let Some(hex) = &args.predecessor_digest {
if let Err(e) = decode_root_hex(hex) {
eprintln!("error: --predecessor-digest: {e}");
return 2;
}
eprintln!(
"note: --predecessor-digest shape OK; per-entry binding \
extraction from the audit log is a follow-up."
);
}
if let Some(expected_hex) = &args.merkle_root {
let expected = match decode_root_hex(expected_hex) {
Ok(bytes) => bytes,
Err(e) => {
eprintln!("error: --merkle-root: {e}");
return 2;
}
};
let computed = match merkle_root_from_log(&content) {
Ok(root) => root,
Err(e) => {
eprintln!("error: failed to recompute merkle root: {e}");
return 1;
}
};
if computed != expected {
eprintln!(
"FAIL: merkle root mismatch — expected {}, computed {}",
expected_hex,
hex_lower(&computed),
);
return 1;
}
println!(
"OK. {} entries. Hash chain intact. All signatures valid. Merkle root matches.",
verified_count
);
} else {
println!(
"OK. {} entries. Hash chain intact. All signatures valid.",
verified_count
);
}
0
}
fn decode_root_hex(input: &str) -> Result<[u8; 32], String> {
let trimmed = input.trim();
let trimmed = trimmed.strip_prefix("sha256:").unwrap_or(trimmed);
if trimmed.len() != 64 {
return Err(format!(
"expected 64 hex chars (32 bytes), got {}",
trimmed.len()
));
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let s = &trimmed[i * 2..i * 2 + 2];
*byte = u8::from_str_radix(s, 16).map_err(|e| format!("byte {i}: {e}"))?;
}
Ok(out)
}
fn hex_lower(bytes: &[u8; 32]) -> String {
let mut s = String::with_capacity(64);
for b in bytes {
use std::fmt::Write;
write!(s, "{b:02x}").expect("write hex");
}
s
}
fn merkle_root_from_log(jsonl: &str) -> Result<[u8; 32], String> {
use invariant_core::merkle::{leaf_hash, MerkleAccumulator};
let mut acc = MerkleAccumulator::new();
for (idx, line) in jsonl.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value =
serde_json::from_str(line).map_err(|e| format!("line {}: parse: {e}", idx + 1))?;
let entry_hash = value
.get("entry_hash")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("line {}: missing entry_hash", idx + 1))?;
acc.push_leaf_hash(leaf_hash(entry_hash.as_bytes()));
}
Ok(acc.root())
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{engine::general_purpose::STANDARD, Engine};
use chrono::Utc;
use ed25519_dalek::Signer;
use invariant_robotics::audit::AuditLogger;
use invariant_robotics::authority::crypto::generate_keypair;
use invariant_robotics::models::authority::Operation;
use invariant_robotics::models::command::{Command, CommandAuthority, JointState};
use invariant_robotics::models::verdict::{
AuthoritySummary, CheckResult, SignedVerdict, Verdict,
};
use rand::rngs::OsRng;
use std::collections::HashMap;
use std::io::Write;
use tempfile::NamedTempFile;
fn make_command() -> Command {
Command {
timestamp: Utc::now(),
source: "test".into(),
sequence: 1,
joint_states: vec![JointState {
name: "j1".into(),
position: 0.0,
velocity: 1.0,
effort: 10.0,
}],
delta_time: 0.01,
end_effector_positions: vec![],
center_of_mass: None,
authority: CommandAuthority {
pca_chain: String::new(),
required_ops: vec![Operation::new("actuate:j1").unwrap()],
},
metadata: HashMap::new(),
locomotion_state: None,
end_effector_forces: vec![],
estimated_payload_kg: None,
signed_sensor_readings: vec![],
zone_overrides: HashMap::new(),
environment_state: None,
}
}
fn make_signed_verdict(signing_key: &ed25519_dalek::SigningKey) -> SignedVerdict {
let verdict = Verdict {
approved: true,
command_hash: "sha256:abc".into(),
command_sequence: 1,
timestamp: Utc::now(),
checks: vec![CheckResult {
name: "authority".into(),
category: "authority".into(),
passed: true,
details: "ok".into(),
derating: None,
}],
profile_name: "test_robot".into(),
profile_hash: "sha256:def".into(),
threat_analysis: None,
authority_summary: AuthoritySummary {
origin_principal: "alice".into(),
hop_count: 1,
operations_granted: vec!["actuate:*".into()],
operations_required: vec!["actuate:j1".into()],
},
};
let verdict_json = serde_json::to_vec(&verdict).unwrap();
let signature = signing_key.sign(&verdict_json);
SignedVerdict {
verdict,
verdict_signature: STANDARD.encode(signature.to_bytes()),
signer_kid: "test-kid".into(),
}
}
fn write_valid_audit_log(signing_key: &ed25519_dalek::SigningKey, n: usize) -> NamedTempFile {
let mut tmp = NamedTempFile::new().unwrap();
let cmd = make_command();
let verdict = make_signed_verdict(signing_key);
let mut logger = AuditLogger::new(&mut tmp, signing_key.clone(), "test-kid".into());
for _ in 0..n {
logger.log(&cmd, &verdict).unwrap();
}
tmp.flush().unwrap();
tmp
}
fn write_pubkey_file(signing_key: &ed25519_dalek::SigningKey) -> NamedTempFile {
let vk = signing_key.verifying_key();
let kf = crate::key_file::KeyFile {
kid: "test-kid".into(),
public_key: STANDARD.encode(vk.as_bytes()),
secret_key: None,
};
let mut tmp = NamedTempFile::new().unwrap();
tmp.write_all(serde_json::to_string_pretty(&kf).unwrap().as_bytes())
.unwrap();
tmp.flush().unwrap();
tmp
}
fn args_for(log: &std::path::Path, pubkey: &std::path::Path) -> VerifyArgs {
VerifyArgs {
log: log.to_path_buf(),
pubkey: pubkey.to_path_buf(),
merkle_root: None,
predecessor_digest: None,
}
}
#[test]
fn valid_log_and_pubkey_returns_0() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 3);
let key_file = write_pubkey_file(&sk);
let args = args_for(log_file.path(), key_file.path());
assert_eq!(run(&args), 0);
}
#[test]
fn empty_log_returns_0() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 0);
let key_file = write_pubkey_file(&sk);
let args = args_for(log_file.path(), key_file.path());
assert_eq!(run(&args), 0);
}
#[test]
fn nonexistent_log_returns_2() {
let sk = generate_keypair(&mut OsRng);
let key_file = write_pubkey_file(&sk);
let args = VerifyArgs {
log: PathBuf::from("/nonexistent/audit.jsonl"),
pubkey: key_file.path().to_path_buf(),
merkle_root: None,
predecessor_digest: None,
};
assert_eq!(run(&args), 2);
}
#[test]
fn nonexistent_pubkey_returns_2() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 1);
let args = VerifyArgs {
log: log_file.path().to_path_buf(),
pubkey: PathBuf::from("/nonexistent/key.json"),
merkle_root: None,
predecessor_digest: None,
};
assert_eq!(run(&args), 2);
}
#[test]
fn wrong_key_returns_1() {
let sk1 = generate_keypair(&mut OsRng);
let sk2 = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk1, 2);
let key_file = write_pubkey_file(&sk2);
let args = args_for(log_file.path(), key_file.path());
assert_eq!(run(&args), 1);
}
#[test]
fn invalid_pubkey_json_returns_2() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 1);
let mut bad_key_file = NamedTempFile::new().unwrap();
writeln!(bad_key_file, "not valid json").unwrap();
bad_key_file.flush().unwrap();
let args = args_for(log_file.path(), bad_key_file.path());
assert_eq!(run(&args), 2);
}
#[test]
fn tampered_log_returns_1() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 1);
let key_file = write_pubkey_file(&sk);
let content = std::fs::read_to_string(log_file.path()).unwrap();
let tampered = content.replace("sha256:", "sha256:TAMPERED");
let mut bad_log = NamedTempFile::new().unwrap();
bad_log.write_all(tampered.as_bytes()).unwrap();
bad_log.flush().unwrap();
let args = args_for(bad_log.path(), key_file.path());
assert_eq!(run(&args), 1);
}
#[test]
fn small_log_file_is_within_size_limit_and_returns_ok() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 1);
let key_file = write_pubkey_file(&sk);
let meta = std::fs::metadata(log_file.path()).unwrap();
assert!(
meta.len() < 512 * 1024 * 1024,
"test log must be smaller than 512 MiB"
);
let args = args_for(log_file.path(), key_file.path());
assert_eq!(run(&args), 0, "small log must pass size check");
}
fn expected_root_hex_for_log(jsonl: &str) -> String {
super::merkle_root_from_log(jsonl)
.map(|root| super::hex_lower(&root))
.expect("recompute root for fixture log")
}
#[test]
fn merkle_root_matching_returns_0() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 3);
let key_file = write_pubkey_file(&sk);
let content = std::fs::read_to_string(log_file.path()).unwrap();
let expected = expected_root_hex_for_log(&content);
let mut args = args_for(log_file.path(), key_file.path());
args.merkle_root = Some(expected);
assert_eq!(run(&args), 0);
}
#[test]
fn merkle_root_mismatch_returns_1() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 3);
let key_file = write_pubkey_file(&sk);
let mut args = args_for(log_file.path(), key_file.path());
args.merkle_root =
Some("0000000000000000000000000000000000000000000000000000000000000000".into());
assert_eq!(run(&args), 1);
}
#[test]
fn merkle_root_malformed_hex_returns_2() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 1);
let key_file = write_pubkey_file(&sk);
let mut args = args_for(log_file.path(), key_file.path());
args.merkle_root = Some("a".repeat(63));
assert_eq!(run(&args), 2);
}
#[test]
fn merkle_root_with_sha256_prefix_is_accepted() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 2);
let key_file = write_pubkey_file(&sk);
let content = std::fs::read_to_string(log_file.path()).unwrap();
let expected = expected_root_hex_for_log(&content);
let mut args = args_for(log_file.path(), key_file.path());
args.merkle_root = Some(format!("sha256:{expected}"));
assert_eq!(run(&args), 0);
}
#[test]
fn merkle_root_on_empty_log_matches_empty_tree_hash() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 0);
let key_file = write_pubkey_file(&sk);
let empty_root = super::hex_lower(&invariant_core::merkle::empty_tree_hash());
let mut args = args_for(log_file.path(), key_file.path());
args.merkle_root = Some(empty_root);
assert_eq!(run(&args), 0);
}
#[test]
fn predecessor_digest_flag_accepts_well_formed_hex_after_1_2() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 1);
let key_file = write_pubkey_file(&sk);
let mut args = args_for(log_file.path(), key_file.path());
args.predecessor_digest =
Some("0000000000000000000000000000000000000000000000000000000000000000".into());
assert_eq!(run(&args), 0, "well-formed hex now passes shape check");
}
#[test]
fn predecessor_digest_flag_rejects_malformed_hex() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 1);
let key_file = write_pubkey_file(&sk);
let mut args = args_for(log_file.path(), key_file.path());
args.predecessor_digest = Some("deadbeef".into());
assert_eq!(run(&args), 2, "malformed hex must exit 2");
}
#[test]
fn predecessor_digest_flag_accepts_sha256_prefix() {
let sk = generate_keypair(&mut OsRng);
let log_file = write_valid_audit_log(&sk, 1);
let key_file = write_pubkey_file(&sk);
let mut args = args_for(log_file.path(), key_file.path());
args.predecessor_digest = Some(format!(
"sha256:{}",
"0000000000000000000000000000000000000000000000000000000000000000"
));
assert_eq!(run(&args), 0);
}
#[test]
#[ignore]
fn oversized_log_returns_2() {
use std::io::Seek;
let sk = generate_keypair(&mut OsRng);
let key_file = write_pubkey_file(&sk);
let mut log_file = NamedTempFile::new().unwrap();
let size_limit: u64 = 512 * 1024 * 1024;
log_file
.seek(std::io::SeekFrom::Start(size_limit + 1))
.unwrap();
log_file.write_all(b"\x00").unwrap();
log_file.flush().unwrap();
let args = args_for(log_file.path(), key_file.path());
assert_eq!(run(&args), 2, "file exceeding 512 MiB must return 2");
}
}