use anyhow::{Context, Result};
use serde::Serialize;
use std::path::Path;
use crate::cli::CliOutput;
const CTX_WRITE_CHAIN_REPORT: &str = "write chain report";
#[derive(clap::Args, Debug)]
pub struct VerifySignedEventsChainArgs {
#[arg(long, value_name = "SEQUENCE", default_value_t = 0)]
pub since: i64,
#[arg(long, value_name = "FORMAT", default_value = "text")]
pub format: String,
}
#[derive(Debug, Serialize)]
pub struct ChainVerifyReportJson {
pub rows_checked: u64,
pub chain_break: Option<i64>,
pub signature_failures: Vec<i64>,
pub chain_holds: bool,
}
pub fn run(
db_path: &Path,
args: &VerifySignedEventsChainArgs,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let conn =
crate::db::open(db_path).with_context(|| format!("open db at {}", db_path.display()))?;
let since = if args.since > 0 {
Some(args.since)
} else {
None
};
let report = crate::signed_events::verify_chain(&conn, since)
.context("verify_chain over signed_events")?;
let holds = report.chain_holds();
match args.format.as_str() {
"json" => {
let wire = ChainVerifyReportJson {
rows_checked: report.rows_checked,
chain_break: report.chain_break,
signature_failures: report.signature_failures.clone(),
chain_holds: holds,
};
let json = serde_json::to_string_pretty(&wire).context("serialize chain report")?;
writeln!(out.stdout, "{json}").context(CTX_WRITE_CHAIN_REPORT)?;
}
_ => {
if holds {
writeln!(
out.stdout,
"verify-signed-events-chain OK: {} row(s) walked, chain holds",
report.rows_checked,
)
.context(CTX_WRITE_CHAIN_REPORT)?;
} else {
let where_ = report
.chain_break
.map_or_else(|| "<unknown>".to_string(), |s| s.to_string());
writeln!(
out.stdout,
"verify-signed-events-chain FAIL: chain break at sequence={where_} \
({} row(s) walked)",
report.rows_checked,
)
.context(CTX_WRITE_CHAIN_REPORT)?;
}
}
}
Ok(if holds { 0 } else { 1 })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signed_events::{SignedEvent, append_signed_event, payload_hash};
fn fixture_event(payload: &[u8]) -> SignedEvent {
SignedEvent {
id: uuid::Uuid::new_v4().to_string(),
agent_id: "alice".to_string(),
event_type: crate::signed_events::event_types::MEMORY_LINK_CREATED.to_string(),
payload_hash: payload_hash(payload),
signature: None,
attest_level: "unsigned".to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
..SignedEvent::default()
}
}
fn temp_db() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::Builder::new()
.prefix("verify-signed-events-")
.tempdir()
.expect("tempdir");
let path = dir.path().join("test.db");
drop(crate::db::open(&path).expect("init db"));
(dir, path)
}
#[test]
fn empty_db_reports_zero_rows_chain_holds() {
let (_dir, path) = temp_db();
let args = VerifySignedEventsChainArgs {
since: 0,
format: "json".to_string(),
};
let mut buf_out = Vec::<u8>::new();
let mut buf_err = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
let code = run(&path, &args, &mut out).expect("run");
assert_eq!(code, 0, "empty chain holds vacuously");
let s = String::from_utf8(buf_out).expect("utf-8");
assert!(s.contains("\"chain_holds\": true"), "got: {s}");
assert!(s.contains("\"rows_checked\": 0"), "got: {s}");
}
#[test]
fn populated_db_reports_chain_ok() {
let (_dir, path) = temp_db();
{
let conn = crate::db::open(&path).expect("open");
for i in 0..3 {
append_signed_event(&conn, &fixture_event(format!("payload-{i}").as_bytes()))
.expect("append");
}
}
let args = VerifySignedEventsChainArgs {
since: 0,
format: "text".to_string(),
};
let mut buf_out = Vec::<u8>::new();
let mut buf_err = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
let code = run(&path, &args, &mut out).expect("run");
assert_eq!(code, 0, "3-row clean chain holds; got code={code}");
let s = String::from_utf8(buf_out).expect("utf-8");
assert!(s.contains("OK"), "got: {s}");
assert!(s.contains("3 row(s) walked"), "got: {s}");
}
#[test]
fn since_filter_excludes_lower_sequences() {
let (_dir, path) = temp_db();
{
let conn = crate::db::open(&path).expect("open");
for i in 0..3 {
append_signed_event(&conn, &fixture_event(format!("p-{i}").as_bytes()))
.expect("append");
}
}
let args = VerifySignedEventsChainArgs {
since: 1,
format: "json".to_string(),
};
let mut buf_out = Vec::<u8>::new();
let mut buf_err = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
let code = run(&path, &args, &mut out).expect("run");
assert_eq!(code, 0, "filtered chain still holds");
let s = String::from_utf8(buf_out).expect("utf-8");
assert!(s.contains("\"rows_checked\": 2"), "got: {s}");
}
#[test]
fn broken_chain_text_format_reports_fail_with_sequence() {
let (_dir, path) = temp_db();
{
let conn = crate::db::open(&path).expect("open");
append_signed_event(&conn, &fixture_event(b"p-0")).expect("append-1");
append_signed_event(&conn, &fixture_event(b"p-1")).expect("append-2");
conn.execute(
"INSERT INTO signed_events \
(id, agent_id, event_type, payload_hash, signature, attest_level, \
timestamp, prev_hash, sequence) \
VALUES (?1, ?2, ?3, ?4, NULL, 'unsigned', ?5, X'00', 99)",
rusqlite::params![
uuid::Uuid::new_v4().to_string(),
"alice",
"memory_link.created",
payload_hash(b"p-99"),
chrono::Utc::now().to_rfc3339(),
],
)
.expect("raw INSERT tampered row");
}
let args = VerifySignedEventsChainArgs {
since: 0,
format: "text".to_string(),
};
let mut buf_out = Vec::<u8>::new();
let mut buf_err = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
let code = run(&path, &args, &mut out).expect("run");
assert_eq!(code, 1, "chain break must produce exit code 1");
let s = String::from_utf8(buf_out).expect("utf-8");
assert!(s.contains("FAIL"), "must say FAIL; got: {s}");
assert!(
s.contains("chain break at sequence="),
"must surface break; got: {s}"
);
}
#[test]
fn broken_chain_json_format_carries_chain_break() {
let (_dir, path) = temp_db();
{
let conn = crate::db::open(&path).expect("open");
append_signed_event(&conn, &fixture_event(b"p-0")).expect("append-1");
conn.execute(
"INSERT INTO signed_events \
(id, agent_id, event_type, payload_hash, signature, attest_level, \
timestamp, prev_hash, sequence) \
VALUES (?1, ?2, ?3, ?4, NULL, 'unsigned', ?5, X'00', 42)",
rusqlite::params![
uuid::Uuid::new_v4().to_string(),
"alice",
"memory_link.created",
payload_hash(b"p-42"),
chrono::Utc::now().to_rfc3339(),
],
)
.expect("raw INSERT");
}
let args = VerifySignedEventsChainArgs {
since: 0,
format: "json".to_string(),
};
let mut buf_out = Vec::<u8>::new();
let mut buf_err = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
let code = run(&path, &args, &mut out).expect("run");
assert_eq!(code, 1);
let s = String::from_utf8(buf_out).expect("utf-8");
assert!(s.contains("\"chain_holds\": false"), "got: {s}");
assert!(s.contains("\"chain_break\":"), "got: {s}");
}
#[test]
fn default_format_falls_back_to_text() {
let (_dir, path) = temp_db();
let args = VerifySignedEventsChainArgs {
since: 0,
format: "yaml-unrecognised".to_string(),
};
let mut buf_out = Vec::<u8>::new();
let mut buf_err = Vec::<u8>::new();
let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
let code = run(&path, &args, &mut out).expect("run");
assert_eq!(code, 0);
let s = String::from_utf8(buf_out).expect("utf-8");
assert!(s.contains("OK"), "must hit text branch; got: {s}");
}
}