Skip to main content

bones_core/verify/
mod.rs

1//! Verification utilities for shard manifests and redaction completeness.
2
3pub mod redact;
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::event::parser;
9use crate::shard::{ShardError, ShardManager, ShardManifest};
10
11/// Verification error.
12#[derive(Debug, thiserror::Error)]
13pub enum VerifyError {
14    /// Shard-level I/O or lock error.
15    #[error("shard error: {0}")]
16    Shard(#[from] ShardError),
17
18    /// Generic I/O error.
19    #[error("I/O error: {0}")]
20    Io(#[from] std::io::Error),
21}
22
23/// Per-shard verification result.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ShardCheck {
26    /// Shard name (`YYYY-MM.events`).
27    pub shard_name: String,
28    /// Outcome.
29    pub status: ShardCheckStatus,
30}
31
32/// Status for one shard verification.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ShardCheckStatus {
35    /// Manifest exists and matches shard contents.
36    Verified,
37    /// Manifest was missing and regenerated.
38    Regenerated,
39    /// Manifest mismatch or missing (without regeneration).
40    Failed(String),
41}
42
43/// Aggregate verification report.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct VerifyReport {
46    /// Results for sealed shards.
47    pub shards: Vec<ShardCheck>,
48    /// Active shard parse sanity check status.
49    pub active_shard_parse_ok: bool,
50}
51
52impl VerifyReport {
53    /// Return `true` when all checks passed.
54    #[must_use]
55    pub fn is_ok(&self) -> bool {
56        self.active_shard_parse_ok
57            && self
58                .shards
59                .iter()
60                .all(|s| !matches!(s.status, ShardCheckStatus::Failed(_)))
61    }
62}
63
64/// Verify sealed shard manifests and parse-sanity-check the active shard.
65///
66/// Sealed shard policy:
67/// - Every sealed shard (`all except latest`) must have a manifest.
68/// - If missing and `regenerate_missing` is true, regenerate from shard file.
69/// - Existing manifests must match computed `event_count`, `byte_len`, and `file_hash`.
70///
71/// Active shard policy:
72/// - The active shard is not required to have a manifest.
73/// - Active shard content is parsed for TSJSON sanity.
74///
75/// # Errors
76///
77/// Returns [`VerifyError`] on filesystem access failures.
78pub fn verify_repository(
79    bones_dir: &Path,
80    regenerate_missing: bool,
81) -> Result<VerifyReport, VerifyError> {
82    let mgr = ShardManager::new(bones_dir);
83    let shards = mgr.list_shards()?;
84
85    if shards.is_empty() {
86        return Ok(VerifyReport {
87            shards: Vec::new(),
88            active_shard_parse_ok: true,
89        });
90    }
91
92    let active = shards.last().copied();
93    let mut checks = Vec::new();
94
95    for (year, month) in shards.iter().copied() {
96        if Some((year, month)) == active {
97            continue;
98        }
99
100        let shard_name = ShardManager::shard_filename(year, month);
101        let computed = compute_manifest(&mgr, year, month)?;
102
103        match mgr.read_manifest(year, month)? {
104            Some(existing) => {
105                if existing == computed {
106                    checks.push(ShardCheck {
107                        shard_name,
108                        status: ShardCheckStatus::Verified,
109                    });
110                } else {
111                    checks.push(ShardCheck {
112                        shard_name,
113                        status: ShardCheckStatus::Failed("manifest mismatch".to_string()),
114                    });
115                }
116            }
117            None if regenerate_missing => {
118                let _ = mgr.write_manifest(year, month)?;
119                checks.push(ShardCheck {
120                    shard_name,
121                    status: ShardCheckStatus::Regenerated,
122                });
123            }
124            None => {
125                checks.push(ShardCheck {
126                    shard_name,
127                    status: ShardCheckStatus::Failed("missing manifest".to_string()),
128                });
129            }
130        }
131    }
132
133    let active_shard_parse_ok = if let Some((year, month)) = active {
134        let content = mgr.read_shard(year, month)?;
135        parser::parse_lines(&content).is_ok()
136    } else {
137        true
138    };
139
140    Ok(VerifyReport {
141        shards: checks,
142        active_shard_parse_ok,
143    })
144}
145
146fn compute_manifest(
147    mgr: &ShardManager,
148    year: i32,
149    month: u32,
150) -> Result<ShardManifest, VerifyError> {
151    let path: PathBuf = mgr.shard_path(year, month);
152    let content = fs::read(&path)?;
153    let content_str = String::from_utf8_lossy(&content);
154    let event_count = content_str
155        .lines()
156        .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
157        .count() as u64;
158
159    Ok(ShardManifest {
160        shard_name: ShardManager::shard_filename(year, month),
161        event_count,
162        byte_len: content.len() as u64,
163        file_hash: format!("blake3:{}", blake3::hash(&content).to_hex()),
164    })
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use tempfile::TempDir;
171
172    #[test]
173    fn verify_regenerates_missing_manifest_for_sealed_shard() {
174        let tmp = TempDir::new().expect("tmp");
175        let bones = tmp.path().join(".bones");
176        let mgr = ShardManager::new(&bones);
177        mgr.ensure_dirs().expect("dirs");
178
179        mgr.create_shard(2025, 1).expect("old shard");
180        mgr.append_raw(2025, 1, "e1\n").expect("append");
181
182        // Create a newer active shard so 2025-01 is sealed and missing manifest.
183        mgr.create_shard(2030, 1).expect("new shard");
184
185        let report = verify_repository(&bones, true).expect("verify");
186
187        assert!(report.active_shard_parse_ok);
188        assert!(
189            report
190                .shards
191                .iter()
192                .any(|s| matches!(s.status, ShardCheckStatus::Regenerated))
193        );
194        assert!(mgr.manifest_path(2025, 1).exists());
195    }
196}