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() {
194 if let Ok(inner) = fs::read_dir(&path) {
195 found.extend(
196 inner
197 .filter_map(Result::ok)
198 .map(|entry| entry.path())
199 .filter(|p| is_log(p)),
200 );
201 }
202 }
203 }
204 found.sort();
205 found
206 }
207
208 fn logs_of(&self, layout: &Layout, name: &str, entry: &TableEntry) -> Vec<PathBuf> {
209 let flat = layout.log_path(name);
210 if entry.partition_by.is_some() {
211 let dir = layout.tables().join(name);
212 let parts = partition_names_in(&dir);
213 if !parts.is_empty() {
214 return parts
215 .iter()
216 .map(|part| dir.join(format!("{part}{LOG_SUFFIX}")))
217 .collect();
218 }
219 }
220 if fs::symlink_metadata(&flat).is_ok() {
221 vec![flat]
222 } else {
223 Vec::new()
224 }
225 }
226
227 fn check_table(&mut self, layout: &Layout, name: &str, entry: &TableEntry) {
228 self.report.tables_checked += 1;
229 for path in self.logs_of(layout, name, entry) {
230 self.check_log(name, &path);
231 }
232 }
233
234 fn check_log(&mut self, table: &str, path: &Path) {
235 let file = self.relative(path);
236 let opened = match fs::File::open(path) {
237 Ok(opened) => opened,
238 Err(error) => {
239 let problem = self.push(Severity::Error, "log-unreadable", format!("\"{file}\": {error}"));
240 problem.table = Some(table.to_string());
241 problem.file = Some(file.clone());
242 return;
243 }
244 };
245 let mut reader = BufReader::new(opened);
246 let mut buffer = Vec::new();
247 let mut offset = 0u64;
248 loop {
249 buffer.clear();
250 let read = match reader.read_until(b'\n', &mut buffer) {
251 Ok(0) => break,
252 Ok(read) => read,
253 Err(error) => {
254 let problem = self.push(
255 Severity::Error,
256 "log-unreadable",
257 format!("\"{file}\" stopped being readable at byte {offset}: {error}"),
258 );
259 problem.table = Some(table.to_string());
260 problem.file = Some(file.clone());
261 problem.offset = Some(offset);
262 return;
263 }
264 };
265 let terminated = buffer.last() == Some(&b'\n');
266 if !terminated {
267 let problem = self.push(
268 Severity::Warning,
269 "log-truncated-tail",
270 format!("\"{file}\" ends with {read} bytes of an unfinished line at byte {offset}; a writable open cuts it off"),
271 );
272 problem.table = Some(table.to_string());
273 problem.file = Some(file.clone());
274 problem.offset = Some(offset);
275 return;
276 }
277 let content = &buffer[..buffer.len() - 1];
278 if !content.is_empty() {
279 self.report.log_lines_checked += 1;
280 let reason = match std::str::from_utf8(content) {
281 Ok(line) => parse_entry(line, &file, offset).err().map(|error| error.reason),
282 Err(error) => Some(format!("line is not valid UTF-8 ({error})")),
283 };
284 if let Some(reason) = reason {
285 let problem = self.push(
286 Severity::Error,
287 "log-corrupt-line",
288 format!("\"{file}\" at byte {offset}: {reason}"),
289 );
290 problem.table = Some(table.to_string());
291 problem.file = Some(file.clone());
292 problem.offset = Some(offset);
293 }
294 }
295 offset += read as u64;
296 }
297 }
298
299 fn check_orphan_logs(&mut self, layout: &Layout, catalog: &Catalog) {
300 for path in self.log_files_under(layout) {
301 let Some(table) = table_of_log(layout, &path) else {
302 continue;
303 };
304 if !catalog.tables.contains_key(&table) {
305 let file = self.relative(&path);
306 let problem = self.push(
307 Severity::Warning,
308 "log-undeclared",
309 format!("\"{file}\" belongs to table \"{table}\", which the catalog does not declare"),
310 );
311 problem.table = Some(table);
312 problem.file = Some(file.clone());
313 }
314 }
315 }
316
317 fn check_journal(&mut self, layout: &Layout) {
318 let path = layout.journal_path();
319 if fs::symlink_metadata(&path).is_ok() {
320 let file = self.relative(&path);
321 self.push(
322 Severity::Warning,
323 "journal-unfinished",
324 format!(
325 "\"{file}\" is a unit of work that never finished; the next writable open completes or discards it"
326 ),
327 )
328 .file = Some(file.clone());
329 }
330 }
331
332 fn check_images(&mut self, database: &Database, verify: &VerifyOptions) {
333 for name in database.table_names() {
334 let Some(table) = database.table(name) else {
335 continue;
336 };
337 let images = match table.list_images() {
338 Ok(images) => images,
339 Err(error) => {
340 let problem = self.push(
341 Severity::Error,
342 "image-unreadable",
343 format!("table \"{name}\": {error}"),
344 );
345 problem.table = Some(name.to_string());
346 continue;
347 }
348 };
349 for image in images {
350 let file = self.relative(&table.image_dir().join(&image.physical_path));
351 let mut report = |checker: &mut Self, severity, code, message: String| {
352 let problem = checker.push(severity, code, message);
353 problem.table = Some(name.to_string());
354 problem.file = Some(file.clone());
355 };
356 if image.is_missing() {
357 let who = &image.referenced_by[0];
358 report(
359 self,
360 Severity::Error,
361 "image-missing",
362 format!(
363 "record \"{}\" (field \"{}\") references \"{}\", which is not on disk",
364 who.key, who.field, image.physical_path
365 ),
366 );
367 continue;
368 }
369 match table.read_image(&image.physical_path) {
370 Ok(Some(bytes)) if physical_path_of(&bytes) != image.physical_path => report(
371 self,
372 Severity::Error,
373 "image-corrupt",
374 format!("\"{}\" no longer hashes to its own name", image.physical_path),
375 ),
376 Ok(_) => {}
377 Err(error) => report(
378 self,
379 Severity::Error,
380 "image-unreadable",
381 format!("\"{}\": {error}", image.physical_path),
382 ),
383 }
384 if image.referenced_by.is_empty() {
385 report(
386 self,
387 Severity::Warning,
388 "image-orphan",
389 format!("\"{}\" is referenced by no record", image.physical_path),
390 );
391 }
392 if verify.max_image_bytes.is_some_and(|max| image.bytes > max) {
393 report(
394 self,
395 Severity::Warning,
396 "image-oversize",
397 format!(
398 "\"{}\" is {} bytes, over the configured maximum",
399 image.physical_path, image.bytes
400 ),
401 );
402 }
403 }
404 }
405 }
406
407 fn check_open(&mut self, verify: &VerifyOptions) {
408 let options = OpenOptions {
409 readonly: true,
410 lock: LockMode::Skip,
411 ..OpenOptions::default()
412 };
413 let database = match Database::open_with(&self.root, options) {
414 Ok(database) => database,
415 Err(error) => {
416 self.push(Severity::Error, "open-failed", error.to_string());
417 return;
418 }
419 };
420 if verify.images {
421 self.check_images(&database, verify);
422 }
423 if !verify.relations {
424 return;
425 }
426 match database.check_integrity() {
427 Ok(integrity) => {
428 for orphan in integrity.orphans {
429 let problem = self.push(
430 Severity::Warning,
431 "relation-orphan",
432 format!(
433 "relation \"{}\": {}.{} = {} of record {} points at nothing",
434 orphan.relation, orphan.table, orphan.field, orphan.value, orphan.primary_key
435 ),
436 );
437 problem.table = Some(orphan.table);
438 }
439 }
440 Err(error) => {
441 self.push(Severity::Error, "integrity-check-failed", error.to_string());
442 }
443 }
444 }
445}
446
447fn table_of_log(layout: &Layout, path: &Path) -> Option<String> {
449 let relative = path.strip_prefix(layout.tables()).ok()?;
450 let mut parts = relative.components();
451 let first = parts.next()?.as_os_str().to_string_lossy().to_string();
452 if parts.next().is_some() {
453 Some(first)
454 } else {
455 first.strip_suffix(LOG_SUFFIX).map(str::to_string)
456 }
457}