Skip to main content

hayson_engine/database/
verify.rs

1use std::fs;
2use std::io::{self, BufRead, BufReader};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use serde_json::Value;
7
8use crate::catalog::{parse_catalog, read_catalog_text, Catalog, ReadCatalogError, TableEntry};
9use crate::crypto::{CipherOptions, Sealer};
10use crate::schema::physical_path_of;
11use crate::storage::{parse_entry, partition_names_in, LOG_SUFFIX};
12
13use super::database::{Database, LockMode, OpenOptions};
14use super::layout::{Layout, LayoutOptions};
15
16/// How much a finding matters to someone about to open the directory.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Severity {
19    /// The directory cannot be opened as it is, or data in it cannot be read back.
20    Error,
21    /// Opening works; the engine repairs it on the next writable open, or it is only worth a look.
22    Warning,
23}
24
25/// One finding of [`verify`]. `code` is a stable kebab-case identifier callers can match on;
26/// `message` is for people.
27#[derive(Debug, Clone, PartialEq)]
28pub struct Problem {
29    pub severity: Severity,
30    pub code: &'static str,
31    pub table: Option<String>,
32    /// Path of the file concerned, relative to the database directory.
33    pub file: Option<String>,
34    /// Byte offset of the offending line in `file`.
35    pub offset: Option<u64>,
36    pub message: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct VerifyOptions {
41    /// Also open the database read-only (no lock) and report relation orphans. Skipped when the
42    /// structural checks already found an error, because the open would only repeat it.
43    pub relations: bool,
44    /// Also check every table's image files: a referenced file that is missing, a file whose bytes
45    /// no longer hash to its name, a file no record references and, with `max_image_bytes`, a file
46    /// over that size. Reads every image file, and shares the read-only open `relations` uses.
47    pub images: bool,
48    /// Size in bytes above which an existing image file is reported as `image-oversize`.
49    pub max_image_bytes: Option<u64>,
50    /// The keys of an encrypted database. Without them its catalog cannot be opened, and verifying
51    /// it ends with one `catalog-encrypted` problem; with them every sealed line and image is
52    /// opened and checked like a plain one.
53    pub encryption: Option<CipherOptions>,
54}
55
56impl Default for VerifyOptions {
57    fn default() -> Self {
58        VerifyOptions {
59            relations: true,
60            images: true,
61            max_image_bytes: None,
62            encryption: None,
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Default)]
68pub struct VerifyReport {
69    pub problems: Vec<Problem>,
70    pub tables_checked: usize,
71    pub log_lines_checked: u64,
72}
73
74impl VerifyReport {
75    pub fn has_errors(&self) -> bool {
76        self.problems.iter().any(|p| p.severity == Severity::Error)
77    }
78
79    pub fn is_clean(&self) -> bool {
80        self.problems.is_empty()
81    }
82}
83
84/// Checks a database directory without taking its lock or changing a byte, and reports what is
85/// wrong with it instead of failing on it: every damaged, missing or unreadable file becomes a
86/// [`Problem`], never an `Err` and never a panic.
87///
88/// Looks at the metadata document, each declared table's log files line by line, logs the catalog
89/// does not declare, an unfinished unit-of-work journal and (optionally) declared relations.
90pub fn verify(dir: &Path, options: &VerifyOptions) -> VerifyReport {
91    let mut report = VerifyReport::default();
92    let sealer = match options.encryption.as_ref().map(Sealer::new).transpose() {
93        Ok(sealer) => sealer.map(Arc::new),
94        Err(error) => {
95            report.problems.push(Problem {
96                severity: Severity::Error,
97                code: "encryption-key-invalid",
98                table: None,
99                file: None,
100                offset: None,
101                message: error.to_string(),
102            });
103            return report;
104        }
105    };
106    let mut checker = Checker {
107        root: dir.to_path_buf(),
108        report: &mut report,
109        sealer,
110    };
111    checker.run(options);
112    report
113}
114
115struct Checker<'a> {
116    root: PathBuf,
117    report: &'a mut VerifyReport,
118    sealer: Option<Arc<Sealer>>,
119}
120
121impl Checker<'_> {
122    fn push(&mut self, severity: Severity, code: &'static str, message: String) -> &mut Problem {
123        self.report.problems.push(Problem {
124            severity,
125            code,
126            table: None,
127            file: None,
128            offset: None,
129            message,
130        });
131        self.report.problems.last_mut().expect("just pushed")
132    }
133
134    fn relative(&self, path: &Path) -> String {
135        path.strip_prefix(&self.root).unwrap_or(path).display().to_string()
136    }
137
138    fn run(&mut self, options: &VerifyOptions) {
139        if !self.root.is_dir() {
140            let message = format!("\"{}\" is not a directory", self.root.display());
141            self.push(Severity::Error, "directory-missing", message);
142            return;
143        }
144        let layout =
145            match Database::resolve_layout_sealed(&self.root, &LayoutOptions::default(), self.sealer.as_deref()) {
146                Ok(layout) => layout,
147                Err(error) => {
148                    self.push(Severity::Error, "layout-unresolved", error.to_string());
149                    return;
150                }
151            };
152        let Some(catalog) = self.read_catalog(&layout) else {
153            return;
154        };
155
156        for (name, entry) in &catalog.tables {
157            self.check_table(&layout, name, entry);
158        }
159        self.check_orphan_logs(&layout, &catalog);
160        self.check_journal(&layout);
161
162        if (options.relations || options.images) && !self.report.has_errors() {
163            self.check_open(options);
164        }
165    }
166
167    fn read_catalog(&mut self, layout: &Layout) -> Option<Catalog> {
168        let path = layout.metadata_path();
169        let file = self.relative(path);
170        let raw = match read_catalog_text(path, self.sealer.as_deref()) {
171            Ok(Some(raw)) => raw,
172            Ok(None) => {
173                let holds_logs = !self.log_files_under(layout).is_empty();
174                let severity = if holds_logs { Severity::Error } else { Severity::Warning };
175                self.push(severity, "catalog-missing", format!("\"{file}\" does not exist"))
176                    .file = Some(file.clone());
177                return None;
178            }
179            Err(error) => {
180                let code = error.problem_code();
181                let reason = match error {
182                    ReadCatalogError::Io(error) => error.to_string(),
183                    ReadCatalogError::Cipher(error)
184                    | ReadCatalogError::Encrypted(error)
185                    | ReadCatalogError::NotEncrypted(error) => error.to_string(),
186                };
187                self.push(Severity::Error, code, format!("\"{file}\": {reason}")).file = Some(file.clone());
188                return None;
189            }
190        };
191        let value: Value = match serde_json::from_str(&raw) {
192            Ok(value) => value,
193            Err(error) => {
194                self.push(
195                    Severity::Error,
196                    "catalog-invalid-json",
197                    format!("\"{file}\" is not valid JSON: {error}"),
198                )
199                .file = Some(file.clone());
200                return None;
201            }
202        };
203        match parse_catalog(&value) {
204            Ok(catalog) => Some(catalog),
205            Err(error) => {
206                self.push(Severity::Error, "catalog-invalid", format!("\"{file}\": {error}"))
207                    .file = Some(file.clone());
208                None
209            }
210        }
211    }
212
213    /// Every `*.jsonl` file directly in the tables directory or one level below it.
214    fn log_files_under(&self, layout: &Layout) -> Vec<PathBuf> {
215        let is_log = |path: &Path| path.is_file() && path.to_string_lossy().ends_with(LOG_SUFFIX);
216        let mut found = Vec::new();
217        let Ok(top) = fs::read_dir(layout.tables()) else {
218            return found;
219        };
220        for path in top.filter_map(Result::ok).map(|entry| entry.path()) {
221            if is_log(&path) {
222                found.push(path);
223            } else if path.is_dir() && !path.join(layout.metadata_file()).is_file() {
224                // NOTE: a folder with its own metadata document is another database, not a partition directory.
225                if let Ok(inner) = fs::read_dir(&path) {
226                    found.extend(
227                        inner
228                            .filter_map(Result::ok)
229                            .map(|entry| entry.path())
230                            .filter(|p| is_log(p)),
231                    );
232                }
233            }
234        }
235        found.sort();
236        found
237    }
238
239    fn logs_of(&self, layout: &Layout, name: &str, entry: &TableEntry) -> Vec<PathBuf> {
240        let flat = layout.log_path(name);
241        if entry.partition_by.is_some() {
242            let dir = layout.tables().join(name);
243            let parts = partition_names_in(&dir);
244            if !parts.is_empty() {
245                return parts
246                    .iter()
247                    .map(|part| dir.join(format!("{part}{LOG_SUFFIX}")))
248                    .collect();
249            }
250        }
251        if fs::symlink_metadata(&flat).is_ok() {
252            vec![flat]
253        } else {
254            Vec::new()
255        }
256    }
257
258    fn check_table(&mut self, layout: &Layout, name: &str, entry: &TableEntry) {
259        self.report.tables_checked += 1;
260        for path in self.logs_of(layout, name, entry) {
261            self.check_log(name, &path);
262        }
263    }
264
265    fn check_log(&mut self, table: &str, path: &Path) {
266        let file = self.relative(path);
267        let opened = match fs::File::open(path) {
268            Ok(opened) => opened,
269            Err(error) => {
270                let problem = self.push(Severity::Error, "log-unreadable", format!("\"{file}\": {error}"));
271                problem.table = Some(table.to_string());
272                problem.file = Some(file.clone());
273                return;
274            }
275        };
276        let mut reader = BufReader::new(opened);
277        let mut buffer = Vec::new();
278        let mut offset = 0u64;
279        loop {
280            buffer.clear();
281            let read = match reader.read_until(b'\n', &mut buffer) {
282                Ok(0) => break,
283                Ok(read) => read,
284                Err(error) => {
285                    let problem = self.push(
286                        Severity::Error,
287                        "log-unreadable",
288                        format!("\"{file}\" stopped being readable at byte {offset}: {error}"),
289                    );
290                    problem.table = Some(table.to_string());
291                    problem.file = Some(file.clone());
292                    problem.offset = Some(offset);
293                    return;
294                }
295            };
296            let terminated = buffer.last() == Some(&b'\n');
297            if !terminated {
298                let problem = self.push(
299                    Severity::Warning,
300                    "log-truncated-tail",
301                    format!("\"{file}\" ends with {read} bytes of an unfinished line at byte {offset}; a writable open cuts it off"),
302                );
303                problem.table = Some(table.to_string());
304                problem.file = Some(file.clone());
305                problem.offset = Some(offset);
306                return;
307            }
308            let content = &buffer[..buffer.len() - 1];
309            if !content.is_empty() {
310                self.report.log_lines_checked += 1;
311                let reason = match std::str::from_utf8(content) {
312                    Ok(line) => match &self.sealer {
313                        Some(sealer) => match sealer.open_line(&format!("log:{table}"), line) {
314                            Ok(plain) => parse_entry(&plain, &file, offset).err().map(|error| error.reason),
315                            Err(error) => Some(error.reason),
316                        },
317                        None => parse_entry(line, &file, offset).err().map(|error| error.reason),
318                    },
319                    Err(error) => Some(format!("line is not valid UTF-8 ({error})")),
320                };
321                if let Some(reason) = reason {
322                    let problem = self.push(
323                        Severity::Error,
324                        "log-corrupt-line",
325                        format!("\"{file}\" at byte {offset}: {reason}"),
326                    );
327                    problem.table = Some(table.to_string());
328                    problem.file = Some(file.clone());
329                    problem.offset = Some(offset);
330                }
331            }
332            offset += read as u64;
333        }
334    }
335
336    fn check_orphan_logs(&mut self, layout: &Layout, catalog: &Catalog) {
337        for path in self.log_files_under(layout) {
338            let Some(table) = table_of_log(layout, &path) else {
339                continue;
340            };
341            if !catalog.tables.contains_key(&table) {
342                let file = self.relative(&path);
343                let problem = self.push(
344                    Severity::Warning,
345                    "log-undeclared",
346                    format!("\"{file}\" belongs to table \"{table}\", which the catalog does not declare"),
347                );
348                problem.table = Some(table);
349                problem.file = Some(file.clone());
350            }
351        }
352    }
353
354    fn check_journal(&mut self, layout: &Layout) {
355        let path = layout.journal_path();
356        if fs::symlink_metadata(&path).is_ok() {
357            let file = self.relative(&path);
358            self.push(
359                Severity::Warning,
360                "journal-unfinished",
361                format!(
362                    "\"{file}\" is a unit of work that never finished; the next writable open completes or discards it"
363                ),
364            )
365            .file = Some(file.clone());
366        }
367    }
368
369    fn check_images(&mut self, database: &Database, verify: &VerifyOptions) {
370        for name in database.table_names() {
371            let Some(table) = database.table(name) else {
372                continue;
373            };
374            let images = match table.list_images() {
375                Ok(images) => images,
376                Err(error) => {
377                    let problem = self.push(
378                        Severity::Error,
379                        "image-unreadable",
380                        format!("table \"{name}\": {error}"),
381                    );
382                    problem.table = Some(name.to_string());
383                    continue;
384                }
385            };
386            for image in images {
387                let file = self.relative(&table.image_dir().join(&image.physical_path));
388                let mut report = |checker: &mut Self, severity, code, message: String| {
389                    let problem = checker.push(severity, code, message);
390                    problem.table = Some(name.to_string());
391                    problem.file = Some(file.clone());
392                };
393                if image.is_missing() {
394                    let who = &image.referenced_by[0];
395                    report(
396                        self,
397                        Severity::Error,
398                        "image-missing",
399                        format!(
400                            "record \"{}\" (field \"{}\") references \"{}\", which is not on disk",
401                            who.key, who.field, image.physical_path
402                        ),
403                    );
404                    continue;
405                }
406                match table.read_image(&image.physical_path) {
407                    // NOTE: in an encrypted database the seal's own tag already authenticates the bytes.
408                    Ok(Some(bytes)) if self.sealer.is_none() && physical_path_of(&bytes) != image.physical_path => {
409                        report(
410                            self,
411                            Severity::Error,
412                            "image-corrupt",
413                            format!("\"{}\" no longer hashes to its own name", image.physical_path),
414                        )
415                    }
416                    Ok(_) => {}
417                    Err(error) => report(
418                        self,
419                        Severity::Error,
420                        "image-unreadable",
421                        format!("\"{}\": {error}", image.physical_path),
422                    ),
423                }
424                if image.referenced_by.is_empty() {
425                    report(
426                        self,
427                        Severity::Warning,
428                        "image-orphan",
429                        format!("\"{}\" is referenced by no record", image.physical_path),
430                    );
431                }
432                if verify.max_image_bytes.is_some_and(|max| image.bytes > max) {
433                    report(
434                        self,
435                        Severity::Warning,
436                        "image-oversize",
437                        format!(
438                            "\"{}\" is {} bytes, over the configured maximum",
439                            image.physical_path, image.bytes
440                        ),
441                    );
442                }
443            }
444        }
445    }
446
447    fn check_open(&mut self, verify: &VerifyOptions) {
448        let options = OpenOptions {
449            readonly: true,
450            lock: LockMode::Skip,
451            encryption: verify.encryption.clone(),
452            ..OpenOptions::default()
453        };
454        let database = match Database::open_with(&self.root, options) {
455            Ok(database) => database,
456            Err(error) => {
457                self.push(Severity::Error, "open-failed", error.to_string());
458                return;
459            }
460        };
461        if verify.images {
462            self.check_images(&database, verify);
463        }
464        if !verify.relations {
465            return;
466        }
467        match database.check_integrity() {
468            Ok(integrity) => {
469                for orphan in integrity.orphans {
470                    let problem = self.push(
471                        Severity::Warning,
472                        "relation-orphan",
473                        format!(
474                            "relation \"{}\": {}.{} = {} of record {} points at nothing",
475                            orphan.relation, orphan.table, orphan.field, orphan.value, orphan.primary_key
476                        ),
477                    );
478                    problem.table = Some(orphan.table);
479                }
480            }
481            Err(error) => {
482                self.push(Severity::Error, "integrity-check-failed", error.to_string());
483            }
484        }
485    }
486}
487
488/// The table a log file belongs to: `<table>.jsonl`, or `<table>/<part>.jsonl` one level down.
489fn table_of_log(layout: &Layout, path: &Path) -> Option<String> {
490    let relative = path.strip_prefix(layout.tables()).ok()?;
491    let mut parts = relative.components();
492    let first = parts.next()?.as_os_str().to_string_lossy().to_string();
493    if parts.next().is_some() {
494        Some(first)
495    } else {
496        first.strip_suffix(LOG_SUFFIX).map(str::to_string)
497    }
498}