1use std::collections::HashMap;
15use std::path::Path;
16
17use anyhow::{Context, Result};
18use rusqlite::{Connection, params};
19use serde::Serialize;
20
21use crate::event::Event;
22use crate::event::data::EventData;
23use crate::event::parser::parse_lines;
24use crate::event::types::EventType;
25use crate::shard::ShardManager;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct RedactionReport {
34 pub redactions_checked: usize,
36 pub passed: usize,
38 pub failed: usize,
40 pub failures: Vec<RedactionFailure>,
42}
43
44impl RedactionReport {
45 #[must_use]
47 pub const fn is_ok(&self) -> bool {
48 self.failed == 0
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct RedactionFailure {
55 pub item_id: String,
57 pub event_hash: String,
59 pub residual_locations: Vec<ResidualLocation>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub enum ResidualLocation {
66 MissingRedactionRecord,
68 CommentNotRedacted {
70 comment_id: i64,
72 },
73 Fts5Index {
75 matched_term: String,
77 },
78}
79
80#[derive(Debug)]
86struct RedactionTarget {
87 item_id: String,
89 target_hash: String,
91 _reason: String,
93 original_event_type: Option<EventType>,
95 original_text: Option<String>,
97}
98
99pub fn verify_redactions(events_dir: &Path, db: &Connection) -> Result<RedactionReport> {
117 let targets = collect_redaction_targets(events_dir)?;
118 let total = targets.len();
119
120 let mut failures = Vec::new();
121 for target in &targets {
122 let locs = check_residuals(db, target)?;
123 if !locs.is_empty() {
124 failures.push(RedactionFailure {
125 item_id: target.item_id.clone(),
126 event_hash: target.target_hash.clone(),
127 residual_locations: locs,
128 });
129 }
130 }
131
132 let failed = failures.len();
133 Ok(RedactionReport {
134 redactions_checked: total,
135 passed: total - failed,
136 failed,
137 failures,
138 })
139}
140
141pub fn verify_item_redaction(
156 item_id: &str,
157 events_dir: &Path,
158 db: &Connection,
159) -> Result<Vec<RedactionFailure>> {
160 let all_targets = collect_redaction_targets(events_dir)?;
161 let item_targets: Vec<_> = all_targets
162 .into_iter()
163 .filter(|t| t.item_id == item_id)
164 .collect();
165
166 let mut failures = Vec::new();
167 for target in &item_targets {
168 let locs = check_residuals(db, target)?;
169 if !locs.is_empty() {
170 failures.push(RedactionFailure {
171 item_id: target.item_id.clone(),
172 event_hash: target.target_hash.clone(),
173 residual_locations: locs,
174 });
175 }
176 }
177 Ok(failures)
178}
179
180fn collect_redaction_targets(events_dir: &Path) -> Result<Vec<RedactionTarget>> {
187 let dot = Path::new(".");
188 let bones_dir = events_dir.parent().unwrap_or(dot);
189 let shard_mgr = ShardManager::new(bones_dir);
190
191 let content = shard_mgr
192 .replay()
193 .map_err(|e| anyhow::anyhow!("replay shards: {e}"))?;
194
195 if content.trim().is_empty() {
196 return Ok(Vec::new());
197 }
198
199 let events = parse_lines(&content)
200 .map_err(|(line_num, e)| anyhow::anyhow!("parse error at line {line_num}: {e}"))?;
201
202 let events_by_hash: HashMap<&str, &Event> =
204 events.iter().map(|e| (e.event_hash.as_str(), e)).collect();
205
206 let mut targets = Vec::new();
208 for event in &events {
209 if event.event_type != EventType::Redact {
210 continue;
211 }
212 let EventData::Redact(ref redact_data) = event.data else {
213 continue;
214 };
215
216 let original = events_by_hash.get(redact_data.target_hash.as_str());
217 let (original_event_type, original_text) = original.map_or((None, None), |orig| {
218 (Some(orig.event_type), extract_searchable_text(orig))
219 });
220
221 targets.push(RedactionTarget {
222 item_id: event.item_id.as_str().to_string(),
223 target_hash: redact_data.target_hash.clone(),
224 _reason: redact_data.reason.clone(),
225 original_event_type,
226 original_text,
227 });
228 }
229
230 Ok(targets)
231}
232
233fn extract_searchable_text(event: &Event) -> Option<String> {
238 match &event.data {
239 EventData::Comment(d) => Some(d.body.clone()),
240 EventData::Create(d) => {
241 let mut parts = vec![d.title.clone()];
242 if let Some(ref desc) = d.description {
243 parts.push(desc.clone());
244 }
245 Some(parts.join(" "))
246 }
247 EventData::Update(d) => {
248 Some(
250 d.value
251 .as_str()
252 .map_or_else(|| d.value.to_string(), str::to_string),
253 )
254 }
255 EventData::Compact(d) => Some(d.summary.clone()),
256 _ => None,
257 }
258}
259
260fn check_residuals(db: &Connection, target: &RedactionTarget) -> Result<Vec<ResidualLocation>> {
266 let mut locations = Vec::new();
267
268 check_redaction_record(db, target, &mut locations)?;
270
271 check_comment_redacted(db, target, &mut locations)?;
274
275 check_fts5_residual(db, target, &mut locations)?;
277
278 Ok(locations)
279}
280
281fn check_redaction_record(
283 db: &Connection,
284 target: &RedactionTarget,
285 locations: &mut Vec<ResidualLocation>,
286) -> Result<()> {
287 let exists: bool = db
288 .query_row(
289 "SELECT EXISTS(SELECT 1 FROM event_redactions WHERE target_event_hash = ?1)",
290 params![target.target_hash],
291 |row| row.get(0),
292 )
293 .context("check event_redactions for target hash")?;
294
295 if !exists {
296 locations.push(ResidualLocation::MissingRedactionRecord);
297 }
298 Ok(())
299}
300
301fn check_comment_redacted(
303 db: &Connection,
304 target: &RedactionTarget,
305 locations: &mut Vec<ResidualLocation>,
306) -> Result<()> {
307 let mut stmt = db
309 .prepare("SELECT comment_id, body FROM item_comments WHERE event_hash = ?1")
310 .context("prepare comment redaction check")?;
311
312 let rows: Vec<(i64, String)> = stmt
313 .query_map(params![target.target_hash], |row| {
314 Ok((row.get(0)?, row.get(1)?))
315 })
316 .context("query comment by event_hash")?
317 .filter_map(Result::ok)
318 .collect();
319
320 for (comment_id, body) in rows {
321 if body != "[redacted]" {
322 locations.push(ResidualLocation::CommentNotRedacted { comment_id });
323 }
324 }
325 Ok(())
326}
327
328fn check_fts5_residual(
340 db: &Connection,
341 target: &RedactionTarget,
342 locations: &mut Vec<ResidualLocation>,
343) -> Result<()> {
344 let is_comment = matches!(target.original_event_type, Some(EventType::Comment));
347 if !is_comment {
348 return Ok(());
349 }
350
351 let text = match &target.original_text {
352 Some(t) if !t.is_empty() => t,
353 _ => return Ok(()),
354 };
355
356 let words = extract_probe_words(text);
358 if words.is_empty() {
359 return Ok(());
360 }
361
362 for word in &words {
365 let fts_query = format!("\"{}\"", word.replace('"', ""));
367 let hit_count: i64 = db
368 .query_row(
369 "SELECT COUNT(*) FROM items_fts \
370 WHERE items_fts MATCH ?1 AND item_id = ?2",
371 params![fts_query, target.item_id],
372 |row| row.get(0),
373 )
374 .unwrap_or(0);
375
376 if hit_count > 0 {
377 locations.push(ResidualLocation::Fts5Index {
378 matched_term: word.clone(),
379 });
380 break;
382 }
383 }
384
385 Ok(())
386}
387
388fn extract_probe_words(text: &str) -> Vec<String> {
393 const STOP_WORDS: &[&str] = &[
394 "the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "her", "was", "one",
395 "our", "out", "has", "have", "been", "from", "this", "that", "they", "with", "which",
396 "their", "would", "there", "what", "about", "will", "make", "like", "just", "than", "them",
397 "very", "when", "some", "could", "more", "also", "into", "other", "then", "these", "only",
398 "after", "most",
399 ];
400
401 text.split_whitespace()
402 .map(|w| {
403 w.trim_matches(|c: char| !c.is_alphanumeric())
404 .to_lowercase()
405 })
406 .filter(|w| w.len() >= 4 && !STOP_WORDS.contains(&w.as_str()))
407 .take(5) .collect()
409}
410
411#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::db::project;
419 use crate::event::data::*;
420 use crate::event::writer::write_event;
421 use crate::model::item::Kind;
422 use crate::model::item_id::ItemId;
423 use rusqlite::Connection;
424 use std::collections::BTreeMap;
425 use std::time::Duration;
426 use tempfile::TempDir;
427
428 fn setup_test_project() -> (TempDir, Connection) {
434 let dir = TempDir::new().expect("create tempdir");
435 let bones_dir = dir.path().join(".bones");
436 let shard_mgr = ShardManager::new(&bones_dir);
437 shard_mgr.ensure_dirs().expect("ensure dirs");
438 shard_mgr.init().expect("init shard");
439
440 let db_path = bones_dir.join("bones.db");
441 let conn = crate::db::open_projection(&db_path).expect("open projection");
442 project::ensure_tracking_table(&conn).expect("tracking table");
443
444 (dir, conn)
445 }
446
447 fn make_create_event(item_id: &str, title: &str, ts: i64) -> Event {
448 let mut event = Event {
449 wall_ts_us: ts,
450 agent: "test-agent".into(),
451 itc: "itc:AQ".into(),
452 parents: vec![],
453 event_type: EventType::Create,
454 item_id: ItemId::new_unchecked(item_id),
455 data: EventData::Create(CreateData {
456 title: title.into(),
457 kind: Kind::Task,
458 size: None,
459 urgency: crate::model::item::Urgency::Default,
460 labels: vec![],
461 parent: None,
462 causation: None,
463 description: Some(format!("Description for {title}")),
464 extra: BTreeMap::new(),
465 }),
466 event_hash: String::new(),
467 };
468 write_event(&mut event).expect("compute hash");
469 event
470 }
471
472 fn make_comment_event(item_id: &str, body: &str, ts: i64) -> Event {
473 let mut event = Event {
474 wall_ts_us: ts,
475 agent: "test-agent".into(),
476 itc: "itc:AQ".into(),
477 parents: vec![],
478 event_type: EventType::Comment,
479 item_id: ItemId::new_unchecked(item_id),
480 data: EventData::Comment(CommentData {
481 body: body.into(),
482 extra: BTreeMap::new(),
483 }),
484 event_hash: String::new(),
485 };
486 write_event(&mut event).expect("compute hash");
487 event
488 }
489
490 fn make_redact_event(item_id: &str, target_hash: &str, reason: &str, ts: i64) -> Event {
491 let mut event = Event {
492 wall_ts_us: ts,
493 agent: "test-agent".into(),
494 itc: "itc:AQ".into(),
495 parents: vec![],
496 event_type: EventType::Redact,
497 item_id: ItemId::new_unchecked(item_id),
498 data: EventData::Redact(RedactData {
499 target_hash: target_hash.into(),
500 reason: reason.into(),
501 extra: BTreeMap::new(),
502 }),
503 event_hash: String::new(),
504 };
505 write_event(&mut event).expect("compute hash");
506 event
507 }
508
509 fn write_and_project(dir: &TempDir, conn: &Connection, events: &[Event]) {
511 let bones_dir = dir.path().join(".bones");
512 let shard_mgr = ShardManager::new(&bones_dir);
513 let projector = project::Projector::new(conn);
514
515 for event in events {
516 let mut event_clone = event.clone();
517 let line = write_event(&mut event_clone).expect("serialize event");
518 shard_mgr
519 .append(&line, false, Duration::from_secs(5))
520 .expect("append event");
521 projector.project_event(event).expect("project event");
522 }
523 }
524
525 #[test]
530 fn probe_words_filters_short_and_stop_words() {
531 let words = extract_probe_words("the quick brown fox jumps over the lazy dog");
532 assert!(words.contains(&"quick".to_string()));
533 assert!(words.contains(&"brown".to_string()));
534 assert!(words.contains(&"jumps".to_string()));
535 assert!(words.contains(&"lazy".to_string()));
536 assert!(!words.contains(&"the".to_string()));
537 assert!(!words.contains(&"fox".to_string())); assert!(!words.contains(&"dog".to_string())); }
540
541 #[test]
542 fn probe_words_limits_to_five() {
543 let text = "alpha bravo charlie delta echo foxtrot golf hotel india juliet";
544 let words = extract_probe_words(text);
545 assert!(words.len() <= 5);
546 }
547
548 #[test]
549 fn probe_words_handles_empty_text() {
550 assert!(extract_probe_words("").is_empty());
551 assert!(extract_probe_words(" ").is_empty());
552 }
553
554 #[test]
555 fn probe_words_strips_punctuation() {
556 let words = extract_probe_words("hello! world? testing... (works)");
557 assert!(words.contains(&"hello".to_string()));
558 assert!(words.contains(&"world".to_string()));
559 assert!(words.contains(&"testing".to_string()));
560 assert!(words.contains(&"works".to_string()));
561 }
562
563 #[test]
568 fn searchable_text_from_comment() {
569 let event = make_comment_event("bn-test", "Secret API key: abc123", 1000);
570 let text = extract_searchable_text(&event);
571 assert_eq!(text, Some("Secret API key: abc123".into()));
572 }
573
574 #[test]
575 fn searchable_text_from_create() {
576 let event = make_create_event("bn-test", "Fix auth timeout", 1000);
577 let text = extract_searchable_text(&event);
578 let t = text.unwrap();
579 assert!(t.contains("Fix auth timeout"));
580 assert!(t.contains("Description for Fix auth timeout"));
581 }
582
583 #[test]
588 fn verify_redactions_empty_log() {
589 let (dir, conn) = setup_test_project();
590 let events_dir = dir.path().join(".bones").join("events");
591
592 let report = verify_redactions(&events_dir, &conn).expect("verify");
593 assert_eq!(report.redactions_checked, 0);
594 assert_eq!(report.passed, 0);
595 assert_eq!(report.failed, 0);
596 assert!(report.is_ok());
597 }
598
599 #[test]
600 fn verify_redactions_no_redacts() {
601 let (dir, conn) = setup_test_project();
602
603 let create = make_create_event("bn-tst1", "Normal item", 1000);
604 write_and_project(&dir, &conn, &[create]);
605
606 let events_dir = dir.path().join(".bones").join("events");
607 let report = verify_redactions(&events_dir, &conn).expect("verify");
608 assert_eq!(report.redactions_checked, 0);
609 assert!(report.is_ok());
610 }
611
612 #[test]
613 fn verify_redactions_comment_properly_redacted() {
614 let (dir, conn) = setup_test_project();
615
616 let create = make_create_event("bn-tst1", "Test item", 1000);
617 let comment = make_comment_event("bn-tst1", "Contains secret info", 2000);
618 let redact = make_redact_event("bn-tst1", &comment.event_hash, "accidental secret", 3000);
619
620 write_and_project(&dir, &conn, &[create, comment, redact]);
621
622 let events_dir = dir.path().join(".bones").join("events");
623 let report = verify_redactions(&events_dir, &conn).expect("verify");
624 assert_eq!(report.redactions_checked, 1);
625 assert_eq!(report.passed, 1);
626 assert_eq!(report.failed, 0);
627 assert!(report.is_ok());
628 }
629
630 #[test]
631 fn verify_detects_missing_redaction_record() {
632 let (dir, conn) = setup_test_project();
633
634 let create = make_create_event("bn-tst1", "Test item", 1000);
635 let comment = make_comment_event("bn-tst1", "Secret info", 2000);
636
637 write_and_project(&dir, &conn, &[create, comment.clone()]);
639
640 let redact = make_redact_event("bn-tst1", &comment.event_hash, "accidental secret", 3000);
642 let bones_dir = dir.path().join(".bones");
643 let shard_mgr = ShardManager::new(&bones_dir);
644 let mut redact_clone = redact.clone();
645 let line = write_event(&mut redact_clone).expect("serialize");
646 shard_mgr
647 .append(&line, false, Duration::from_secs(5))
648 .expect("append");
649
650 let events_dir = bones_dir.join("events");
651 let report = verify_redactions(&events_dir, &conn).expect("verify");
652 assert_eq!(report.redactions_checked, 1);
653 assert_eq!(report.failed, 1);
654
655 let failure = &report.failures[0];
656 assert_eq!(failure.item_id, "bn-tst1");
657 assert!(
658 failure
659 .residual_locations
660 .iter()
661 .any(|l| matches!(l, ResidualLocation::MissingRedactionRecord))
662 );
663 assert!(
664 failure
665 .residual_locations
666 .iter()
667 .any(|l| matches!(l, ResidualLocation::CommentNotRedacted { .. }))
668 );
669 }
670
671 #[test]
672 fn verify_item_redaction_filters_by_item() {
673 let (dir, conn) = setup_test_project();
674
675 let create1 = make_create_event("bn-aaa", "Item A", 1000);
676 let create2 = make_create_event("bn-bbb", "Item B", 1001);
677 let comment1 = make_comment_event("bn-aaa", "Secret A", 2000);
678 let comment2 = make_comment_event("bn-bbb", "Secret B", 2001);
679 let redact1 = make_redact_event("bn-aaa", &comment1.event_hash, "reason A", 3000);
680 let redact2 = make_redact_event("bn-bbb", &comment2.event_hash, "reason B", 3001);
681
682 write_and_project(
683 &dir,
684 &conn,
685 &[create1, create2, comment1, comment2, redact1, redact2],
686 );
687
688 let events_dir = dir.path().join(".bones").join("events");
689
690 let failures_a = verify_item_redaction("bn-aaa", &events_dir, &conn).expect("verify A");
692 assert!(failures_a.is_empty(), "item A should pass");
693
694 let failures_b = verify_item_redaction("bn-bbb", &events_dir, &conn).expect("verify B");
696 assert!(failures_b.is_empty(), "item B should pass");
697
698 let failures_none =
700 verify_item_redaction("bn-zzz", &events_dir, &conn).expect("verify nonexistent");
701 assert!(failures_none.is_empty());
702 }
703
704 #[test]
705 fn verify_multiple_redactions_mixed_results() {
706 let (dir, conn) = setup_test_project();
707
708 let create = make_create_event("bn-mix1", "Mixed item", 1000);
709 let comment_ok = make_comment_event("bn-mix1", "Safe comment", 2000);
710 let comment_fail = make_comment_event("bn-mix1", "Dangerous secret", 2001);
711
712 let redact_ok = make_redact_event("bn-mix1", &comment_ok.event_hash, "reason 1", 3000);
714 let redact_fail = make_redact_event("bn-mix1", &comment_fail.event_hash, "reason 2", 3001);
715
716 write_and_project(
718 &dir,
719 &conn,
720 &[
721 create,
722 comment_ok,
723 comment_fail.clone(),
724 redact_ok,
725 redact_fail,
726 ],
727 );
728
729 let events_dir = dir.path().join(".bones").join("events");
730 let report = verify_redactions(&events_dir, &conn).expect("verify");
731
732 assert_eq!(report.redactions_checked, 2);
734 assert_eq!(report.passed, 2);
735 assert_eq!(report.failed, 0);
736 assert!(report.is_ok());
737 }
738
739 #[test]
740 fn report_serializes_to_json() {
741 let report = RedactionReport {
742 redactions_checked: 3,
743 passed: 2,
744 failed: 1,
745 failures: vec![RedactionFailure {
746 item_id: "bn-abc".into(),
747 event_hash: "blake3:deadbeef".into(),
748 residual_locations: vec![
749 ResidualLocation::MissingRedactionRecord,
750 ResidualLocation::CommentNotRedacted { comment_id: 42 },
751 ],
752 }],
753 };
754
755 let json = serde_json::to_string_pretty(&report).expect("serialize");
756 assert!(json.contains("redactions_checked"));
757 assert!(json.contains("bn-abc"));
758 assert!(json.contains("MissingRedactionRecord"));
759 assert!(json.contains("CommentNotRedacted"));
760 }
761
762 #[test]
763 fn report_is_ok_when_no_failures() {
764 let report = RedactionReport {
765 redactions_checked: 5,
766 passed: 5,
767 failed: 0,
768 failures: vec![],
769 };
770 assert!(report.is_ok());
771 }
772
773 #[test]
774 fn report_not_ok_when_failures_exist() {
775 let report = RedactionReport {
776 redactions_checked: 5,
777 passed: 4,
778 failed: 1,
779 failures: vec![RedactionFailure {
780 item_id: "bn-x".into(),
781 event_hash: "blake3:abc".into(),
782 residual_locations: vec![ResidualLocation::MissingRedactionRecord],
783 }],
784 };
785 assert!(!report.is_ok());
786 }
787}