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