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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Severity {
19 Error,
21 Warning,
23}
24
25#[derive(Debug, Clone, PartialEq)]
28pub struct Problem {
29 pub severity: Severity,
30 pub code: &'static str,
31 pub table: Option<String>,
32 pub file: Option<String>,
34 pub offset: Option<u64>,
36 pub message: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct VerifyOptions {
41 pub relations: bool,
44 pub images: bool,
48 pub max_image_bytes: Option<u64>,
50 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
84pub 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 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 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 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
488fn 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}