1use std::collections::BTreeSet;
15use std::path::Path;
16
17use chrono::{DateTime, Utc};
18use serde::Serialize;
19
20use crate::core::audit_trail;
21use crate::core::savings_ledger;
22use crate::core::security_events::{SecurityCounts, parse_action};
23use crate::core::wrapped::format_tokens;
24
25use super::format::{Style, security_phrases};
26use super::snapshot::ValueSnapshot;
27
28#[derive(Debug, Clone, Serialize)]
29pub struct ChainCheck {
30 pub name: &'static str,
31 pub path: Option<String>,
32 pub entries: usize,
33 pub intact: bool,
34 pub first_invalid_at: Option<usize>,
35}
36
37#[derive(Debug, Clone, Default, Serialize)]
40pub struct Evidence {
41 pub entries: u64,
42 pub first_hash: Option<String>,
43 pub last_hash: Option<String>,
44}
45
46impl Evidence {
47 fn push(&mut self, hash: &str) {
48 self.entries += 1;
49 if self.first_hash.is_none() {
50 self.first_hash = Some(hash.to_string());
51 }
52 self.last_hash = Some(hash.to_string());
53 }
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct Proof {
58 pub session_id: Option<String>,
60 pub ledger: ChainCheck,
61 pub audit: ChainCheck,
62 pub tokens_baseline: u64,
64 pub tokens_saved: u64,
66 pub tokens_bounced: u64,
68 pub ledger_evidence: Evidence,
69 pub security: SecurityCounts,
71 pub audit_evidence: Evidence,
72 pub live: Option<ValueSnapshot>,
74 #[serde(skip)]
77 pub active_days: BTreeSet<String>,
78}
79
80impl Proof {
81 pub fn tampered(&self) -> bool {
82 !self.ledger.intact || !self.audit.intact
83 }
84
85 pub fn net_saved(&self) -> u64 {
86 self.tokens_saved.saturating_sub(self.tokens_bounced)
87 }
88
89 pub fn saved_pct(&self) -> Option<f64> {
91 (self.tokens_baseline > 0)
92 .then(|| self.net_saved() as f64 * 100.0 / self.tokens_baseline as f64)
93 }
94}
95
96fn in_session(entry_session: Option<&str>, wanted: Option<&str>) -> bool {
97 wanted.is_none_or(|id| entry_session == Some(id))
98}
99
100pub fn build_from(
103 ledger_path: Option<&Path>,
104 trail_path: Option<&Path>,
105 session: Option<&str>,
106 live: Option<ValueSnapshot>,
107) -> Proof {
108 build_scoped(ledger_path, trail_path, session, live, None)
109}
110
111pub fn build_scoped(
115 ledger_path: Option<&Path>,
116 trail_path: Option<&Path>,
117 session: Option<&str>,
118 live: Option<ValueSnapshot>,
119 scope: Option<&savings_ledger::ProjectScope>,
120) -> Proof {
121 let mut proof = Proof {
122 session_id: session.map(str::to_string),
123 ledger: ChainCheck {
124 name: "savings ledger",
125 path: ledger_path.map(|p| p.display().to_string()),
126 entries: 0,
127 intact: true,
128 first_invalid_at: None,
129 },
130 audit: ChainCheck {
131 name: "audit trail",
132 path: trail_path.map(|p| p.display().to_string()),
133 entries: 0,
134 intact: true,
135 first_invalid_at: None,
136 },
137 tokens_baseline: 0,
138 tokens_saved: 0,
139 tokens_bounced: 0,
140 ledger_evidence: Evidence::default(),
141 security: SecurityCounts::default(),
142 audit_evidence: Evidence::default(),
143 live,
144 active_days: BTreeSet::new(),
145 };
146
147 if let Some(path) = ledger_path {
150 let (verify, visited) = savings_ledger::store::verify_each(path, |ev| {
151 if !in_session(ev.session_id.as_deref(), session)
152 || scope.is_some_and(|scope| !scope.holds_event(ev))
153 || (ev.saved_tokens == 0 && ev.bounce_adjustment == 0)
154 {
155 return;
156 }
157 proof.tokens_baseline = proof.tokens_baseline.saturating_add(ev.baseline_tokens);
158 proof.tokens_saved = proof.tokens_saved.saturating_add(ev.saved_tokens);
159 proof.tokens_bounced = proof.tokens_bounced.saturating_add(ev.bounce_adjustment);
160 proof.ledger_evidence.push(&ev.entry_hash);
161 if let Some(day) = ev.ts.get(..10) {
162 proof.active_days.insert(day.to_string());
163 }
164 });
165 proof.ledger.entries = verify.total.max(visited);
166 proof.ledger.intact = verify.valid;
167 proof.ledger.first_invalid_at = verify.first_invalid_at;
168 }
169
170 if let Some(path) = trail_path {
171 let (verify, evidence) = audit_trail::verified_security_at(path);
175 proof.audit.entries = verify.total_entries;
176 proof.audit.intact = verify.valid;
177 proof.audit.first_invalid_at = verify.first_invalid_at;
178 if let (None, None, Some(evidence)) = (session, scope, evidence) {
179 proof.security = evidence.counts;
180 proof.audit_evidence = Evidence {
181 entries: evidence.entries,
182 first_hash: evidence.first_hash,
183 last_hash: evidence.last_hash,
184 };
185 return proof;
186 }
187 let since = session.and_then(session_started_at);
191 let needle = session.filter(|id| appears_verbatim_in_json(id));
192 let visited = audit_trail::for_each_entry_at(path, since, needle, |entry| {
193 let Some((kind, n, entry_session)) = entry.action.as_deref().and_then(parse_action)
194 else {
195 return;
196 };
197 if !in_session(entry_session, session)
198 || scope.is_some_and(|scope| !scope.holds_session(entry_session))
199 {
200 return;
201 }
202 proof.security.add(kind, n);
203 proof.audit_evidence.push(&entry.entry_hash);
204 });
205 if since.is_none() && needle.is_none() {
206 proof.audit.entries = proof.audit.entries.max(visited);
207 } else if !verify.valid {
208 let all = audit_trail::for_each_entry_at(path, None, None, |_| {});
211 proof.audit.entries = proof.audit.entries.max(all);
212 }
213 }
214 proof
215}
216
217const SESSION_START_SLACK_HOURS: i64 = 24;
220
221fn session_started_at(id: &str) -> Option<DateTime<Utc>> {
225 let stamp = id.get(..15)?;
226 let started = chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%d-%H%M%S").ok()?;
227 Some(started.and_utc() - chrono::Duration::hours(SESSION_START_SLACK_HOURS))
228}
229
230fn appears_verbatim_in_json(text: &str) -> bool {
233 !text.is_empty()
234 && text
235 .chars()
236 .all(|c| c.is_ascii_graphic() && c != '"' && c != '\\' && c != '/')
237}
238
239pub fn build(session: Option<&str>, lifetime: bool) -> Proof {
242 let live = if lifetime {
243 None
244 } else {
245 super::snapshot::load(session)
246 };
247 let session = if lifetime {
248 None
249 } else {
250 session
251 .map(str::to_string)
252 .or_else(|| live.as_ref().map(|s| s.session_id.clone()))
253 .filter(|id| !id.is_empty())
254 };
255 let ledger = savings_ledger::store::default_path();
256 let trail = audit_trail::default_trail_path();
257 build_from(
258 ledger.as_deref(),
259 trail.as_deref(),
260 session.as_deref(),
261 live,
262 )
263}
264
265pub fn build_lifetime_for(scope: &savings_ledger::ProjectScope) -> Proof {
267 let ledger = savings_ledger::store::default_path();
268 let trail = audit_trail::default_trail_path();
269 build_scoped(ledger.as_deref(), trail.as_deref(), None, None, Some(scope))
270}
271
272pub fn verified_security_since(cutoff: Option<DateTime<Utc>>) -> Option<SecurityCounts> {
276 security_since_at(&audit_trail::default_trail_path()?, cutoff)
277}
278
279pub fn verified_security_evidence() -> Option<(SecurityCounts, Evidence)> {
285 security_evidence_at(&audit_trail::default_trail_path()?)
286}
287
288fn security_evidence_at(path: &Path) -> Option<(SecurityCounts, Evidence)> {
291 let tally = audit_trail::verified_signed_tally_at(path)?;
292 Some((
293 tally.counts,
294 Evidence {
295 entries: tally.entries,
296 first_hash: tally.first_hash,
297 last_hash: tally.last_hash,
298 },
299 ))
300}
301
302fn security_since_at(path: &Path, cutoff: Option<DateTime<Utc>>) -> Option<SecurityCounts> {
303 if !audit_trail::verify_chain_at(path).valid {
304 return None;
305 }
306 let mut counts = SecurityCounts::default();
307 for entry in audit_trail::load_all_at(path) {
308 if let Some(cutoff) = cutoff {
309 let Ok(ts) = DateTime::parse_from_rfc3339(&entry.timestamp) else {
310 continue;
311 };
312 if ts.with_timezone(&Utc) < cutoff {
313 continue;
314 }
315 }
316 if let Some((kind, n, _)) = entry.action.as_deref().and_then(parse_action) {
317 counts.add(kind, n);
318 }
319 }
320 Some(counts)
321}
322
323fn short(hash: Option<&String>) -> String {
324 hash.map_or_else(|| "—".to_string(), |h| h.chars().take(12).collect())
325}
326
327struct Marks {
328 measured: String,
329 derived: String,
330 ok: String,
331 bad: String,
332}
333
334impl Marks {
335 fn new(style: Style) -> Self {
336 let paint = |code: &str, text: &str| {
337 if style.color {
338 format!("\x1b[{code}m{text}\x1b[0m")
339 } else {
340 text.to_string()
341 }
342 };
343 let (check, approx, cross) = if style.unicode {
344 ("✓", "≈", "✗")
345 } else {
346 ("OK", "~", "FAIL")
347 };
348 Self {
349 measured: paint("32", &format!("{check} measured")),
350 derived: paint("2", &format!("{approx} derived ")),
351 ok: paint("32", check),
352 bad: paint("31", cross),
353 }
354 }
355}
356
357fn chain_line(check: &ChainCheck, marks: &Marks) -> String {
358 if check.intact {
359 format!(
360 "{} {} intact ({} entries)",
361 marks.ok, check.name, check.entries
362 )
363 } else {
364 format!(
365 "{} {} TAMPERED at entry {}",
366 marks.bad,
367 check.name,
368 check.first_invalid_at.unwrap_or(0)
369 )
370 }
371}
372
373pub fn render(proof: &Proof, style: Style) -> String {
375 let marks = Marks::new(style);
376 let mut out = String::new();
377 let scope = proof
378 .session_id
379 .as_deref()
380 .map_or_else(|| "all sessions".to_string(), |id| format!("session {id}"));
381 out.push_str(&format!("lean-ctx value · {scope}\n\n"));
382
383 if proof.ledger_evidence.entries == 0 && proof.security.is_empty() {
384 out.push_str(" nothing recorded for this scope yet\n");
385 }
386 if proof.ledger_evidence.entries > 0 {
387 out.push_str(&format!(
388 " {} {} tokens kept out of context savings ledger, {} events\n",
389 marks.measured,
390 format_tokens(proof.net_saved()),
391 proof.ledger_evidence.entries
392 ));
393 if proof.tokens_bounced > 0 {
394 out.push_str(&format!(
395 " (net of {} tokens re-read by the agent)\n",
396 format_tokens(proof.tokens_bounced)
397 ));
398 }
399 if let Some(pct) = proof.saved_pct() {
400 out.push_str(&format!(
401 " {} {pct:.0}% of {} tool-input tokens net saved ÷ baseline\n",
402 marks.derived,
403 format_tokens(proof.tokens_baseline)
404 ));
405 }
406 }
407 for phrase in security_phrases(&proof.security) {
408 out.push_str(&format!(" {} {phrase} audit trail\n", marks.measured));
409 }
410
411 out.push('\n');
412 if proof.ledger_evidence.entries > 0 {
413 out.push_str(&format!(
414 " evidence ledger {} … {}\n",
415 short(proof.ledger_evidence.first_hash.as_ref()),
416 short(proof.ledger_evidence.last_hash.as_ref())
417 ));
418 }
419 if proof.audit_evidence.entries > 0 {
420 out.push_str(&format!(
421 " evidence audit {} … {}\n",
422 short(proof.audit_evidence.first_hash.as_ref()),
423 short(proof.audit_evidence.last_hash.as_ref())
424 ));
425 }
426 out.push_str(&format!(
427 " chains {}\n",
428 chain_line(&proof.ledger, &marks)
429 ));
430 out.push_str(&format!(
431 " {}\n",
432 chain_line(&proof.audit, &marks)
433 ));
434
435 if let Some(line) = proof.live.as_ref().and_then(|snap| {
436 super::format::one_line(
437 snap,
438 Style {
439 color: false,
440 ..style
441 },
442 )
443 }) {
444 out.push_str(&format!(
445 "\n live {line}\n (display counters; the chains above are the proof)\n"
446 ));
447 }
448 if proof.tampered() {
449 out.push_str(
450 "\n A chain failed verification: the numbers above are NOT proof.\n\
451 \x20 Inspect the file at the reported entry; `lean-ctx savings verify` shows details.\n",
452 );
453 }
454 out
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::core::audit_trail::{AuditEntryData, AuditEventType};
461 use crate::core::savings_ledger::SavingsEvent;
462
463 fn ledger_event(saved: u64, session: Option<&str>) -> SavingsEvent {
464 let mut json = serde_json::json!({
465 "ts": "2026-09-25T10:00:00+00:00",
466 "tool": "ctx_read",
467 "mechanism": "compression",
468 "model_id": "m",
469 "tokenizer": "o200k_base",
470 "baseline_tokens": saved + 100,
471 "actual_tokens": 100,
472 "saved_tokens": saved,
473 "bounce_adjustment": 0,
474 "unit_price_per_m_usd": 3.0,
475 "saved_usd": 0.0,
476 "repo_hash": "r",
477 "agent_id": "a",
478 "prev_hash": "",
479 "entry_hash": "",
480 "version": "3.10.3",
481 });
482 if let Some(id) = session {
483 json["session_id"] = id.into();
484 }
485 serde_json::from_value(json).unwrap()
486 }
487
488 fn security(path: &Path, action: &str) {
489 audit_trail::record_at(
490 path,
491 AuditEntryData {
492 agent_id: "a".into(),
493 tool: "ctx_shell".into(),
494 action: Some(action.into()),
495 input_hash: String::new(),
496 output_tokens: 0,
497 role: "coder".into(),
498 event_type: AuditEventType::SecretDetected,
499 },
500 );
501 }
502
503 fn fixture() -> (
507 crate::core::data_dir::IsolatedDataDir,
508 std::path::PathBuf,
509 std::path::PathBuf,
510 ) {
511 let dir = crate::core::data_dir::isolated_data_dir();
512 let ledger = dir.path().join("ledger.jsonl");
513 let trail = dir.path().join("trail.jsonl");
514 savings_ledger::store::append(&ledger, ledger_event(1_000, Some("s1"))).unwrap();
515 savings_ledger::store::append(&ledger, ledger_event(500, Some("s2"))).unwrap();
516 savings_ledger::store::append(&ledger, ledger_event(250, None)).unwrap();
517 security(&trail, "secret_redacted:2|session=s1");
518 security(&trail, "shell_blocked:1|session=s2");
519 security(&trail, "tool_call");
520 (dir, ledger, trail)
521 }
522
523 #[test]
524 fn session_scope_counts_only_that_session() {
525 let (_dir, ledger, trail) = fixture();
526 let proof = build_from(Some(&ledger), Some(&trail), Some("s1"), None);
527 assert!(!proof.tampered());
528 assert_eq!(proof.tokens_saved, 1_000);
529 assert_eq!(proof.ledger_evidence.entries, 1);
530 assert_eq!(proof.security.secrets_redacted, 2);
531 assert_eq!(proof.security.shell_blocked, 0);
532 assert_eq!(proof.audit_evidence.entries, 1);
533 assert_eq!(proof.ledger.entries, 3);
534 assert_eq!(proof.audit.entries, 3);
535 }
536
537 #[test]
541 fn engine_session_view_counts_the_same_as_a_full_read() {
542 let (_dir, ledger, trail) = fixture();
543 let id = crate::core::session::SessionState::new().id;
544 savings_ledger::store::append(&ledger, ledger_event(70, Some(&id))).unwrap();
545 security(&trail, &format!("secret_redacted:3|session={id}"));
546 security(&trail, &format!("shell_blocked:1|session={id}x"));
547
548 let proof = build_from(Some(&ledger), Some(&trail), Some(&id), None);
549 assert!(!proof.tampered());
550 assert_eq!(proof.tokens_saved, 70);
551 assert_eq!(proof.ledger_evidence.entries, 1);
552 assert_eq!(proof.ledger.entries, 4);
553 assert_eq!(proof.security.secrets_redacted, 3);
554 assert_eq!(
555 proof.security.shell_blocked, 0,
556 "a longer id is another session"
557 );
558 assert_eq!(proof.audit_evidence.entries, 1);
559 assert_eq!(proof.audit.entries, 5);
560 }
561
562 #[test]
563 fn session_start_comes_from_an_engine_id_less_the_slack() {
564 let started = session_started_at("20261111-195640u478865p2526s0").unwrap();
565 assert_eq!(
566 started,
567 "2026-11-10T19:56:40Z".parse::<DateTime<Utc>>().unwrap()
568 );
569 assert_eq!(
570 session_started_at("20261111-195640-478865-2526").map(|t| t.to_rfc3339()),
571 Some("2026-11-10T19:56:40+00:00".into()),
572 "the legacy id format"
573 );
574 assert_eq!(session_started_at("s1"), None);
575 assert_eq!(session_started_at("claude-7f3e-…"), None);
576 }
577
578 #[test]
579 fn only_ids_json_leaves_unescaped_filter_lines() {
580 assert!(appears_verbatim_in_json("20261111-195640u478865p2526s0"));
581 assert!(!appears_verbatim_in_json(""));
582 for escaped in ["a\"b", "a\\b", "a/b", "a b", "naïve"] {
583 assert!(!appears_verbatim_in_json(escaped), "{escaped}");
584 }
585 }
586
587 #[test]
590 fn a_broken_ledger_is_flagged_and_still_summed() {
591 let (_dir, ledger, trail) = fixture();
592 let text = std::fs::read_to_string(&ledger).unwrap();
593 std::fs::write(
594 &ledger,
595 text.replacen("\"saved_tokens\":500", "\"saved_tokens\":900", 1),
596 )
597 .unwrap();
598 let expected = savings_ledger::store::verify(&ledger);
599 assert!(!expected.valid);
600
601 let proof = build_from(Some(&ledger), Some(&trail), None, None);
602 assert!(proof.tampered());
603 assert!(!proof.ledger.intact);
604 assert_eq!(proof.ledger.first_invalid_at, expected.first_invalid_at);
605 assert_eq!(proof.ledger.entries, 3);
606 assert_eq!(proof.tokens_saved, 1_000 + 900 + 250);
607 }
608
609 #[test]
612 fn project_scope_counts_only_that_projects_events_and_sessions() {
613 let (dir, ledger, trail) = fixture();
614 let project = dir.path().join("project");
615 std::fs::create_dir_all(project.join(".git")).unwrap();
616 let root = project.to_string_lossy().into_owned();
617 let other = dir.path().join("other");
618 std::fs::create_dir_all(other.join(".git")).unwrap();
619 let other_root = other.to_string_lossy().into_owned();
620 let mut folder_event = ledger_event(40, None);
621 folder_event.repo_hash = savings_ledger::hash_folder(&root);
622 savings_ledger::store::append(&ledger, folder_event).unwrap();
623 let scope = savings_ledger::ProjectScope::from_sessions(
624 &project,
625 [("s1", root.as_str()), ("s2", other_root.as_str())].into_iter(),
626 );
627
628 let proof = build_scoped(Some(&ledger), Some(&trail), None, None, Some(&scope));
629 assert!(!proof.tampered());
630 assert_eq!(
631 proof.tokens_saved, 1_040,
632 "session s1 plus the project's folder"
633 );
634 assert_eq!(proof.ledger_evidence.entries, 2);
635 assert_eq!(proof.security.secrets_redacted, 2);
636 assert_eq!(
637 proof.security.shell_blocked, 0,
638 "s2 worked in another project"
639 );
640 assert_eq!(proof.security.total(), 2);
641 assert_eq!(
642 proof.ledger.entries, 4,
643 "the chain check still covers the whole ledger"
644 );
645 }
646
647 #[test]
648 fn lifetime_scope_counts_everything() {
649 let (_dir, ledger, trail) = fixture();
650 let proof = build_from(Some(&ledger), Some(&trail), None, None);
651 assert_eq!(proof.tokens_saved, 1_750);
652 assert_eq!(proof.security.total(), 3);
653 let text = render(&proof, Style::PLAIN);
654 assert!(text.contains("✓ measured"), "{text}");
655 assert!(text.contains("2 secrets kept out of context"), "{text}");
656 assert!(text.contains("savings ledger intact (3 entries)"), "{text}");
657 }
658
659 #[test]
660 fn tampered_ledger_is_reported() {
661 let (_dir, ledger, trail) = fixture();
662 let raw = std::fs::read_to_string(&ledger).unwrap();
663 std::fs::write(
664 &ledger,
665 raw.replacen("\"saved_tokens\":1000", "\"saved_tokens\":9000", 1),
666 )
667 .unwrap();
668 let proof = build_from(Some(&ledger), Some(&trail), Some("s1"), None);
669 assert!(proof.tampered());
670 assert!(!proof.ledger.intact);
671 let text = render(&proof, Style::PLAIN);
672 assert!(text.contains("TAMPERED"), "{text}");
673 assert!(text.contains("NOT proof"), "{text}");
674 }
675
676 #[test]
677 fn tampered_trail_is_reported() {
678 let (_dir, ledger, trail) = fixture();
679 let raw = std::fs::read_to_string(&trail).unwrap();
680 std::fs::write(
681 &trail,
682 raw.replacen("secret_redacted:2", "secret_redacted:9", 1),
683 )
684 .unwrap();
685 let proof = build_from(Some(&ledger), Some(&trail), None, None);
686 assert!(proof.tampered());
687 assert!(!proof.audit.intact);
688 }
689
690 #[test]
693 fn a_session_view_of_a_tampered_trail_counts_every_entry() {
694 let (_dir, ledger, trail) = fixture();
695 let raw = std::fs::read_to_string(&trail).unwrap();
696 std::fs::write(
697 &trail,
698 raw.replacen("secret_redacted:2", "secret_redacted:9", 1),
699 )
700 .unwrap();
701 let lifetime = build_from(Some(&ledger), Some(&trail), None, None);
702 let session = build_from(Some(&ledger), Some(&trail), Some("s2"), None);
703 assert!(!session.audit.intact);
704 assert_eq!(
705 session.audit.first_invalid_at,
706 lifetime.audit.first_invalid_at
707 );
708 assert_eq!(session.audit.entries, lifetime.audit.entries);
709 assert_eq!(session.audit.entries, 3);
710 }
711
712 #[test]
713 fn missing_chains_are_empty_and_intact() {
714 let dir = tempfile::tempdir().unwrap();
715 let proof = build_from(
716 Some(&dir.path().join("none.jsonl")),
717 Some(&dir.path().join("none2.jsonl")),
718 Some("x"),
719 None,
720 );
721 assert!(!proof.tampered());
722 assert!(render(&proof, Style::PLAIN).contains("nothing recorded"));
723 }
724
725 #[test]
726 fn security_since_filters_by_time_and_refuses_a_tampered_trail() {
727 let (_dir, _ledger, trail) = fixture();
728 let all = security_since_at(&trail, None).unwrap();
729 assert_eq!(all.total(), 3);
730 let future = Utc::now() + chrono::Duration::days(1);
731 assert!(security_since_at(&trail, Some(future)).unwrap().is_empty());
732 let past = Utc::now() - chrono::Duration::days(7);
733 assert_eq!(security_since_at(&trail, Some(past)).unwrap(), all);
734
735 let raw = std::fs::read_to_string(&trail).unwrap();
736 std::fs::write(
737 &trail,
738 raw.replacen("secret_redacted:2", "secret_redacted:9", 1),
739 )
740 .unwrap();
741 assert_eq!(security_since_at(&trail, None), None);
742 }
743
744 #[test]
745 fn security_evidence_carries_the_hashes_and_refuses_a_tampered_trail() {
746 let (_dir, _ledger, trail) = fixture();
747 let (counts, evidence) = security_evidence_at(&trail).unwrap();
748 assert_eq!(counts.total(), 3);
749 assert_eq!(evidence.entries, 2);
750 assert!(evidence.first_hash.is_some() && evidence.last_hash.is_some());
751
752 let raw = std::fs::read_to_string(&trail).unwrap();
753 std::fs::write(
754 &trail,
755 raw.replacen("shell_blocked:1", "shell_blocked:5", 1),
756 )
757 .unwrap();
758 assert!(security_evidence_at(&trail).is_none());
759 }
760
761 fn full_parse_evidence(path: &Path) -> (SecurityCounts, Evidence) {
764 let mut counts = SecurityCounts::default();
765 let mut evidence = Evidence::default();
766 for entry in audit_trail::load_all_at(path) {
767 if let Some((kind, n, _)) = entry.action.as_deref().and_then(parse_action) {
768 if kind.in_signed_tally() {
769 counts.add(kind, n);
770 evidence.push(&entry.entry_hash);
771 }
772 }
773 }
774 (counts, evidence)
775 }
776
777 #[test]
781 fn resumed_security_evidence_matches_a_full_parse_after_appends() {
782 let iso = crate::core::data_dir::isolated_data_dir();
783 let trail = audit_trail::default_trail_path().unwrap();
784 let bytes = |(counts, evidence): &(SecurityCounts, Evidence)| {
785 serde_json::to_string(&(counts, evidence)).unwrap()
786 };
787 security(&trail, "pii_redacted:4");
788 assert_eq!(
789 bytes(&security_evidence_at(&trail).unwrap()),
790 bytes(&full_parse_evidence(&trail))
791 );
792 let checkpoints = iso.path().join("audit").join("verified");
793 assert!(
794 std::fs::read_dir(&checkpoints).unwrap().next().is_some(),
795 "the first pass leaves a checkpoint to resume from"
796 );
797 for action in [
798 "secret_redacted:2|session=s1",
799 "tool_call",
800 "shell_blocked:1",
801 "path_blocked:3|session=s2",
802 ] {
803 security(&trail, action);
804 assert_eq!(
805 bytes(&security_evidence_at(&trail).unwrap()),
806 bytes(&full_parse_evidence(&trail)),
807 "after appending {action}"
808 );
809 }
810 assert_eq!(security_evidence_at(&trail).unwrap().1.entries, 3);
811 }
812
813 #[test]
817 fn lifetime_proof_from_the_verified_state_matches_a_full_parse() {
818 let _iso = crate::core::data_dir::isolated_data_dir();
819 let trail = audit_trail::default_trail_path().unwrap();
820 let bytes = |counts: &SecurityCounts, evidence: &Evidence| {
821 serde_json::to_string(&(counts, evidence)).unwrap()
822 };
823 for action in [
824 "pii_redacted:4",
825 "secret_redacted:2|session=s1",
826 "tool_call",
827 "shell_blocked:1",
828 "path_blocked:3|session=s2",
829 ] {
830 security(&trail, action);
831 let proof = build_from(None, Some(&trail), None, None);
832 let mut counts = SecurityCounts::default();
833 let mut evidence = Evidence::default();
834 for entry in audit_trail::load_all_at(&trail) {
835 if let Some((kind, n, _)) = entry.action.as_deref().and_then(parse_action) {
836 counts.add(kind, n);
837 evidence.push(&entry.entry_hash);
838 }
839 }
840 assert_eq!(
841 bytes(&proof.security, &proof.audit_evidence),
842 bytes(&counts, &evidence),
843 "after appending {action}"
844 );
845 assert_eq!(proof.audit.entries, audit_trail::load_all_at(&trail).len());
846 assert!(!proof.tampered());
847 }
848 }
849
850 #[test]
851 fn json_has_labels_tooling_needs() {
852 let (_dir, ledger, trail) = fixture();
853 let proof = build_from(Some(&ledger), Some(&trail), Some("s2"), None);
854 let json = serde_json::to_value(&proof).unwrap();
855 assert_eq!(json["tokens_saved"], 500);
856 assert_eq!(json["security"]["shell_blocked"], 1);
857 assert_eq!(json["ledger"]["intact"], true);
858 assert!(json["ledger_evidence"]["last_hash"].is_string());
859 }
860}