1use anyhow::{Context, Result};
34use serde::Serialize;
35use std::path::Path;
36
37use crate::cli::CliOutput;
38
39const CTX_WRITE_CHAIN_REPORT: &str = "write chain report";
41
42#[derive(clap::Args, Debug)]
44pub struct VerifySignedEventsChainArgs {
45 #[arg(long, value_name = "SEQUENCE", default_value_t = 0)]
49 pub since: i64,
50
51 #[arg(long, value_name = "FORMAT", default_value = "text")]
53 pub format: String,
54}
55
56#[derive(Debug, Serialize)]
61pub struct ChainVerifyReportJson {
62 pub rows_checked: u64,
63 pub chain_break: Option<i64>,
64 pub signature_failures: Vec<i64>,
65 pub chain_holds: bool,
66}
67
68pub fn run(
76 db_path: &Path,
77 args: &VerifySignedEventsChainArgs,
78 out: &mut CliOutput<'_>,
79) -> Result<i32> {
80 let conn =
81 crate::db::open(db_path).with_context(|| format!("open db at {}", db_path.display()))?;
82 let since = if args.since > 0 {
83 Some(args.since)
84 } else {
85 None
86 };
87 let report = crate::signed_events::verify_chain(&conn, since)
88 .context("verify_chain over signed_events")?;
89 let holds = report.chain_holds();
90
91 match args.format.as_str() {
92 "json" => {
93 let wire = ChainVerifyReportJson {
94 rows_checked: report.rows_checked,
95 chain_break: report.chain_break,
96 signature_failures: report.signature_failures.clone(),
97 chain_holds: holds,
98 };
99 let json = serde_json::to_string_pretty(&wire).context("serialize chain report")?;
100 writeln!(out.stdout, "{json}").context(CTX_WRITE_CHAIN_REPORT)?;
101 }
102 _ => {
103 if holds {
105 writeln!(
106 out.stdout,
107 "verify-signed-events-chain OK: {} row(s) walked, chain holds",
108 report.rows_checked,
109 )
110 .context(CTX_WRITE_CHAIN_REPORT)?;
111 } else {
112 let where_ = report
113 .chain_break
114 .map_or_else(|| "<unknown>".to_string(), |s| s.to_string());
115 writeln!(
116 out.stdout,
117 "verify-signed-events-chain FAIL: chain break at sequence={where_} \
118 ({} row(s) walked)",
119 report.rows_checked,
120 )
121 .context(CTX_WRITE_CHAIN_REPORT)?;
122 }
123 }
124 }
125
126 Ok(if holds { 0 } else { 1 })
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::signed_events::{SignedEvent, append_signed_event, payload_hash};
133
134 fn fixture_event(payload: &[u8]) -> SignedEvent {
135 SignedEvent {
136 id: uuid::Uuid::new_v4().to_string(),
137 agent_id: "alice".to_string(),
138 event_type: crate::signed_events::event_types::MEMORY_LINK_CREATED.to_string(),
139 payload_hash: payload_hash(payload),
140 signature: None,
141 attest_level: "unsigned".to_string(),
142 timestamp: chrono::Utc::now().to_rfc3339(),
143 ..SignedEvent::default()
144 }
145 }
146
147 fn temp_db() -> (tempfile::TempDir, std::path::PathBuf) {
148 let dir = tempfile::Builder::new()
149 .prefix("verify-signed-events-")
150 .tempdir()
151 .expect("tempdir");
152 let path = dir.path().join("test.db");
153 drop(crate::db::open(&path).expect("init db"));
154 (dir, path)
155 }
156
157 #[test]
158 fn empty_db_reports_zero_rows_chain_holds() {
159 let (_dir, path) = temp_db();
160 let args = VerifySignedEventsChainArgs {
161 since: 0,
162 format: "json".to_string(),
163 };
164 let mut buf_out = Vec::<u8>::new();
165 let mut buf_err = Vec::<u8>::new();
166 let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
167 let code = run(&path, &args, &mut out).expect("run");
168 assert_eq!(code, 0, "empty chain holds vacuously");
169 let s = String::from_utf8(buf_out).expect("utf-8");
170 assert!(s.contains("\"chain_holds\": true"), "got: {s}");
171 assert!(s.contains("\"rows_checked\": 0"), "got: {s}");
172 }
173
174 #[test]
175 fn populated_db_reports_chain_ok() {
176 let (_dir, path) = temp_db();
177 {
178 let conn = crate::db::open(&path).expect("open");
179 for i in 0..3 {
180 append_signed_event(&conn, &fixture_event(format!("payload-{i}").as_bytes()))
181 .expect("append");
182 }
183 }
184 let args = VerifySignedEventsChainArgs {
185 since: 0,
186 format: "text".to_string(),
187 };
188 let mut buf_out = Vec::<u8>::new();
189 let mut buf_err = Vec::<u8>::new();
190 let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
191 let code = run(&path, &args, &mut out).expect("run");
192 assert_eq!(code, 0, "3-row clean chain holds; got code={code}");
193 let s = String::from_utf8(buf_out).expect("utf-8");
194 assert!(s.contains("OK"), "got: {s}");
195 assert!(s.contains("3 row(s) walked"), "got: {s}");
196 }
197
198 #[test]
215 fn since_filter_excludes_lower_sequences() {
216 let (_dir, path) = temp_db();
218 {
219 let conn = crate::db::open(&path).expect("open");
220 for i in 0..3 {
221 append_signed_event(&conn, &fixture_event(format!("p-{i}").as_bytes()))
222 .expect("append");
223 }
224 }
225 let args = VerifySignedEventsChainArgs {
226 since: 1,
227 format: "json".to_string(),
228 };
229 let mut buf_out = Vec::<u8>::new();
230 let mut buf_err = Vec::<u8>::new();
231 let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
232 let code = run(&path, &args, &mut out).expect("run");
233 assert_eq!(code, 0, "filtered chain still holds");
234 let s = String::from_utf8(buf_out).expect("utf-8");
235 assert!(s.contains("\"rows_checked\": 2"), "got: {s}");
237 }
238
239 #[test]
240 fn broken_chain_text_format_reports_fail_with_sequence() {
241 let (_dir, path) = temp_db();
244 {
245 let conn = crate::db::open(&path).expect("open");
246 append_signed_event(&conn, &fixture_event(b"p-0")).expect("append-1");
248 append_signed_event(&conn, &fixture_event(b"p-1")).expect("append-2");
249 conn.execute(
253 "INSERT INTO signed_events \
254 (id, agent_id, event_type, payload_hash, signature, attest_level, \
255 timestamp, prev_hash, sequence) \
256 VALUES (?1, ?2, ?3, ?4, NULL, 'unsigned', ?5, X'00', 99)",
257 rusqlite::params![
258 uuid::Uuid::new_v4().to_string(),
259 "alice",
260 "memory_link.created",
261 payload_hash(b"p-99"),
262 chrono::Utc::now().to_rfc3339(),
263 ],
264 )
265 .expect("raw INSERT tampered row");
266 }
267 let args = VerifySignedEventsChainArgs {
268 since: 0,
269 format: "text".to_string(),
270 };
271 let mut buf_out = Vec::<u8>::new();
272 let mut buf_err = Vec::<u8>::new();
273 let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
274 let code = run(&path, &args, &mut out).expect("run");
275 assert_eq!(code, 1, "chain break must produce exit code 1");
276 let s = String::from_utf8(buf_out).expect("utf-8");
277 assert!(s.contains("FAIL"), "must say FAIL; got: {s}");
278 assert!(
279 s.contains("chain break at sequence="),
280 "must surface break; got: {s}"
281 );
282 }
283
284 #[test]
285 fn broken_chain_json_format_carries_chain_break() {
286 let (_dir, path) = temp_db();
289 {
290 let conn = crate::db::open(&path).expect("open");
291 append_signed_event(&conn, &fixture_event(b"p-0")).expect("append-1");
292 conn.execute(
293 "INSERT INTO signed_events \
294 (id, agent_id, event_type, payload_hash, signature, attest_level, \
295 timestamp, prev_hash, sequence) \
296 VALUES (?1, ?2, ?3, ?4, NULL, 'unsigned', ?5, X'00', 42)",
297 rusqlite::params![
298 uuid::Uuid::new_v4().to_string(),
299 "alice",
300 "memory_link.created",
301 payload_hash(b"p-42"),
302 chrono::Utc::now().to_rfc3339(),
303 ],
304 )
305 .expect("raw INSERT");
306 }
307 let args = VerifySignedEventsChainArgs {
308 since: 0,
309 format: "json".to_string(),
310 };
311 let mut buf_out = Vec::<u8>::new();
312 let mut buf_err = Vec::<u8>::new();
313 let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
314 let code = run(&path, &args, &mut out).expect("run");
315 assert_eq!(code, 1);
316 let s = String::from_utf8(buf_out).expect("utf-8");
317 assert!(s.contains("\"chain_holds\": false"), "got: {s}");
318 assert!(s.contains("\"chain_break\":"), "got: {s}");
320 }
321
322 #[test]
323 fn default_format_falls_back_to_text() {
324 let (_dir, path) = temp_db();
328 let args = VerifySignedEventsChainArgs {
329 since: 0,
330 format: "yaml-unrecognised".to_string(),
331 };
332 let mut buf_out = Vec::<u8>::new();
333 let mut buf_err = Vec::<u8>::new();
334 let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
335 let code = run(&path, &args, &mut out).expect("run");
336 assert_eq!(code, 0);
337 let s = String::from_utf8(buf_out).expect("utf-8");
338 assert!(s.contains("OK"), "must hit text branch; got: {s}");
339 }
340}