1use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
36use std::path::{Path, PathBuf};
37
38use crate::{ContentHash, LogError, OpEntry, FORMAT_VERSION};
39
40#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Fault {
47 Undecodable {
50 position: u64,
52 detail: String,
54 },
55 UnsupportedFormat {
57 position: u64,
59 found: u16,
61 },
62 BrokenChain {
64 position: u64,
66 expected: Option<ContentHash>,
68 found: Option<ContentHash>,
70 },
71 SeqMismatch {
78 position: u64,
80 found: u64,
82 },
83}
84
85impl Fault {
86 #[must_use]
88 pub fn position(&self) -> u64 {
89 match self {
90 Fault::Undecodable { position, .. }
91 | Fault::UnsupportedFormat { position, .. }
92 | Fault::BrokenChain { position, .. }
93 | Fault::SeqMismatch { position, .. } => *position,
94 }
95 }
96}
97
98impl std::fmt::Display for Fault {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 match self {
101 Fault::Undecodable { position, detail } => write!(
102 f,
103 "record {position} does not decode: {detail}"
104 ),
105 Fault::UnsupportedFormat { position, found } => write!(
106 f,
107 "record {position} uses unsupported format version {found}; this build supports {FORMAT_VERSION}"
108 ),
109 Fault::BrokenChain {
110 position,
111 expected,
112 found,
113 } => write!(
114 f,
115 "record {position} names parent {} but the record before it hashes to {}",
116 found.as_ref().map_or("none".to_string(), ContentHash::to_hex),
117 expected
118 .as_ref()
119 .map_or("none".to_string(), ContentHash::to_hex)
120 ),
121 Fault::SeqMismatch { position, found } => write!(
122 f,
123 "record at position {position} carries seq {found}"
124 ),
125 }
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct ChainReport {
132 pub intact_records: u64,
135 pub fault: Option<Fault>,
137 pub torn_tail_bytes: u64,
140 pub head: Option<ContentHash>,
143}
144
145impl ChainReport {
146 #[must_use]
152 pub fn is_usable(&self) -> bool {
153 self.fault.is_none()
154 }
155
156 #[must_use]
159 pub fn is_repairable(&self) -> bool {
160 self.fault.is_none() && self.torn_tail_bytes > 0
161 }
162}
163
164pub fn verify(path: &Path) -> Result<ChainReport, LogError> {
178 let file = std::fs::File::open(path).map_err(LogError::Io)?;
179 let mut reader = BufReader::new(file);
180 let mut line = Vec::new();
181 let mut head: Option<ContentHash> = None;
182 let mut position = 0u64;
183 let mut torn_tail_bytes = 0u64;
184 let mut fault = None;
185
186 loop {
187 line.clear();
188 let read = reader.read_until(b'\n', &mut line).map_err(LogError::Io)?;
189 if read == 0 {
190 break;
191 }
192 let Some(body) = line.strip_suffix(b"\n") else {
193 torn_tail_bytes = read as u64;
197 break;
198 };
199 let entry: OpEntry = match serde_json::from_slice(body) {
200 Ok(entry) => entry,
201 Err(error) => {
202 fault = Some(Fault::Undecodable {
203 position,
204 detail: error.to_string(),
205 });
206 break;
207 }
208 };
209 if entry.format_version != FORMAT_VERSION {
210 fault = Some(Fault::UnsupportedFormat {
211 position,
212 found: entry.format_version,
213 });
214 break;
215 }
216 if entry.parent != head {
217 fault = Some(Fault::BrokenChain {
218 position,
219 expected: head.clone(),
220 found: entry.parent,
221 });
222 break;
223 }
224 if entry.seq != position {
225 fault = Some(Fault::SeqMismatch {
226 position,
227 found: entry.seq,
228 });
229 break;
230 }
231 head = Some(entry.content_hash());
232 position += 1;
233 }
234
235 Ok(ChainReport {
236 intact_records: position,
237 fault,
238 torn_tail_bytes,
239 head,
240 })
241}
242
243#[derive(Debug, Clone)]
245pub struct Repaired {
246 pub quarantine: PathBuf,
248 pub bytes: u64,
250 pub length: u64,
252}
253
254pub fn truncate_tail(path: &Path) -> Result<Option<Repaired>, LogError> {
271 let report = verify(path)?;
272 if let Some(fault) = report.fault {
273 return Err(LogError::Corrupt(format!(
274 "refusing to truncate: the damage is not a torn tail ({fault}). \
275 Truncating would drop acknowledged ops. Restore from backup."
276 )));
277 }
278 if report.torn_tail_bytes == 0 {
279 return Ok(None);
280 }
281
282 let mut file = std::fs::OpenOptions::new()
283 .read(true)
284 .write(true)
285 .open(path)
286 .map_err(LogError::Io)?;
287 let length = file.metadata().map_err(LogError::Io)?.len();
288 let keep = length - report.torn_tail_bytes;
289
290 let mut torn = vec![0u8; usize::try_from(report.torn_tail_bytes).unwrap_or(usize::MAX)];
291 file.seek(SeekFrom::Start(keep)).map_err(LogError::Io)?;
292 file.read_exact(&mut torn).map_err(LogError::Io)?;
293
294 let quarantine = quarantine_path(path, keep);
295 let mut sidecar = std::fs::File::create(&quarantine).map_err(LogError::Io)?;
296 sidecar.write_all(&torn).map_err(LogError::Io)?;
297 sidecar.sync_all().map_err(LogError::Io)?;
300 drop(sidecar);
301 crate::sync_parent_dir(path)?;
302
303 file.set_len(keep).map_err(LogError::Io)?;
304 file.sync_all().map_err(LogError::Io)?;
305
306 Ok(Some(Repaired {
307 quarantine,
308 bytes: report.torn_tail_bytes,
309 length: keep,
310 }))
311}
312
313pub(crate) fn quarantine_tail(path: &Path, bytes: &[u8], offset: u64) -> Result<PathBuf, LogError> {
328 let quarantine = quarantine_path(path, offset);
329 let mut sidecar = std::fs::File::create(&quarantine).map_err(LogError::Io)?;
330 sidecar.write_all(bytes).map_err(LogError::Io)?;
331 sidecar.sync_all().map_err(LogError::Io)?;
332 drop(sidecar);
333 crate::sync_parent_dir(path)?;
334 Ok(quarantine)
335}
336
337fn quarantine_path(path: &Path, offset: u64) -> PathBuf {
343 let mut name = path.file_name().unwrap_or_default().to_os_string();
344 name.push(format!(".torn-{offset}"));
345 path.with_file_name(name)
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::{OpLog, FORMAT_VERSION};
352
353 fn valid_log(dir: &Path, count: u64) -> PathBuf {
358 let path = dir.join("ops.jsonl");
359 let mut log = crate::FileLog::open(&path).expect("fresh log opens");
360 for seq in 0..count {
361 let entry = OpEntry {
362 format_version: FORMAT_VERSION,
363 parent: log.head(),
364 seq,
365 channel: "ws".into(),
366 payload: format!("op-{seq}").into_bytes(),
367 witnesses: Vec::new(),
368 author_sig: None,
369 };
370 log.append(entry).expect("append");
371 }
372 log.sync().expect("sync");
373 path
374 }
375
376 fn scratch(tag: &str) -> PathBuf {
377 let dir = std::env::temp_dir().join(format!(
378 "choir-repair-{tag}-{}-{:?}",
379 std::process::id(),
380 std::thread::current().id()
381 ));
382 std::fs::create_dir_all(&dir).expect("scratch dir");
383 dir
384 }
385
386 #[test]
387 fn an_intact_log_reports_no_fault() {
388 let dir = scratch("intact");
389 let path = valid_log(&dir, 5);
390 let report = verify(&path).expect("readable");
391 assert_eq!(report.intact_records, 5);
392 assert!(report.fault.is_none(), "{:?}", report.fault);
393 assert_eq!(report.torn_tail_bytes, 0);
394 assert!(report.is_usable());
395 assert!(!report.is_repairable(), "nothing to repair");
396 }
397
398 #[test]
399 fn a_torn_tail_is_reported_as_repairable_not_as_damage() {
400 let dir = scratch("torn");
401 let path = valid_log(&dir, 3);
402 let mut file = std::fs::OpenOptions::new()
405 .append(true)
406 .open(&path)
407 .expect("reopen");
408 file.write_all(br#"{"format_version":1,"seq":3,"chan"#)
409 .expect("partial write");
410 file.sync_all().expect("sync");
411 drop(file);
412
413 let report = verify(&path).expect("readable");
414 assert!(report.fault.is_none(), "a torn tail is not damage");
415 assert_eq!(report.intact_records, 3);
416 assert!(report.torn_tail_bytes > 0);
417 assert!(report.is_repairable());
418 }
419
420 #[test]
421 fn a_broken_chain_is_found_and_located() {
422 let dir = scratch("chain");
423 let path = valid_log(&dir, 4);
424 let text = std::fs::read_to_string(&path).expect("read");
428 let mut lines: Vec<String> = text.lines().map(ToString::to_string).collect();
429 let earlier: serde_json::Value = serde_json::from_str(&lines[1]).expect("record 1 decodes");
436 let mut entry: serde_json::Value =
437 serde_json::from_str(&lines[2]).expect("record 2 decodes");
438 entry["parent"] = earlier["parent"].clone();
439 lines[2] = serde_json::to_string(&entry).expect("re-encode");
440 std::fs::write(&path, lines.join("\n") + "\n").expect("write back");
441
442 let report = verify(&path).expect("readable");
443 assert!(!report.is_usable());
444 assert!(!report.is_repairable(), "this is not a tail problem");
445 assert_eq!(report.intact_records, 2, "records before it are intact");
446 let fault = report.fault.expect("the break is found");
447 assert_eq!(fault.position(), 2, "and located exactly: {fault}");
448 assert!(matches!(fault, Fault::BrokenChain { .. }), "{fault:?}");
449 }
450
451 #[test]
452 fn an_undecodable_record_is_found_and_located() {
453 let dir = scratch("garbage");
454 let path = valid_log(&dir, 4);
455 let text = std::fs::read_to_string(&path).expect("read");
456 let mut lines: Vec<String> = text.lines().map(ToString::to_string).collect();
457 lines[1] = "{not json at all".to_string();
458 std::fs::write(&path, lines.join("\n") + "\n").expect("write back");
459
460 let report = verify(&path).expect("readable");
461 let fault = report.fault.expect("the damage is found");
462 assert_eq!(fault.position(), 1);
463 assert!(matches!(fault, Fault::Undecodable { .. }), "{fault:?}");
464 }
465
466 #[test]
467 fn verifying_does_not_repair() {
468 let dir = scratch("readonly");
472 let path = valid_log(&dir, 2);
473 let mut file = std::fs::OpenOptions::new()
474 .append(true)
475 .open(&path)
476 .expect("reopen");
477 file.write_all(b"partial").expect("partial write");
478 drop(file);
479
480 let before = std::fs::metadata(&path).expect("stat").len();
481 let report = verify(&path).expect("readable");
482 let after = std::fs::metadata(&path).expect("stat").len();
483 assert_eq!(before, after, "verify must not change the file");
484 assert_eq!(report.torn_tail_bytes, 7);
485 }
486
487 #[test]
488 fn repairing_a_tail_quarantines_the_bytes_before_cutting() {
489 let dir = scratch("quarantine");
490 let path = valid_log(&dir, 3);
491 let intact_len = std::fs::metadata(&path).expect("stat").len();
492 let torn = br#"{"format_version":1,"seq":3,"chan"#;
493 let mut file = std::fs::OpenOptions::new()
494 .append(true)
495 .open(&path)
496 .expect("reopen");
497 file.write_all(torn).expect("partial write");
498 drop(file);
499
500 let repaired = truncate_tail(&path)
501 .expect("repairable")
502 .expect("something was repaired");
503 assert_eq!(repaired.bytes, torn.len() as u64);
504 assert_eq!(repaired.length, intact_len, "cut back to the last record");
505
506 let saved = std::fs::read(&repaired.quarantine).expect("sidecar exists");
510 assert_eq!(saved, torn, "the removed bytes are recoverable");
511
512 let after = verify(&path).expect("readable");
514 assert!(after.is_usable(), "{:?}", after.fault);
515 assert_eq!(after.intact_records, 3);
516 assert_eq!(after.torn_tail_bytes, 0);
517 }
518
519 #[test]
527 fn a_node_can_boot_and_keep_writing_after_a_tail_repair() {
528 let dir = scratch("boots");
529 let path = valid_log(&dir, 3);
530 let mut file = std::fs::OpenOptions::new()
531 .append(true)
532 .open(&path)
533 .expect("reopen");
534 file.write_all(br#"{"format_version":1,"seq":3,"wor"#)
535 .expect("partial write");
536 drop(file);
537
538 truncate_tail(&path).expect("repairable").expect("repaired");
539
540 let mut log = crate::FileLog::open(&path).expect("the node opens the repaired log");
541 assert_eq!(log.len(), 3, "the surviving records are all there");
542 assert_eq!(log.torn_tail_bytes(), 0, "and nothing was left torn");
543
544 let head_before = log.head().expect("three records means a head");
545 log.append(OpEntry {
546 format_version: FORMAT_VERSION,
547 parent: Some(head_before),
548 seq: 3,
549 channel: "ws".into(),
550 payload: b"after the repair".to_vec(),
551 witnesses: Vec::new(),
552 author_sig: None,
553 })
554 .expect("the append chains onto the surviving head");
555 log.sync().expect("sync");
556 drop(log);
557
558 let report = verify(&path).expect("readable");
559 assert!(report.is_usable(), "{:?}", report.fault);
560 assert_eq!(report.intact_records, 4, "the new record joined the chain");
561 }
562
563 #[test]
564 fn repairing_refuses_mid_log_damage_and_says_why() {
565 let dir = scratch("refuse");
566 let path = valid_log(&dir, 4);
567 let text = std::fs::read_to_string(&path).expect("read");
568 let mut lines: Vec<String> = text.lines().map(ToString::to_string).collect();
569 lines[1] = "{not json at all".to_string();
570 std::fs::write(&path, lines.join("\n") + "\n").expect("write back");
571 let before = std::fs::read(&path).expect("read");
572
573 let error = truncate_tail(&path).expect_err("must refuse");
574 let message = format!("{error:?}");
575 assert!(
576 message.contains("restore") || message.contains("Restore"),
577 "the refusal must point at the way out: {message}"
578 );
579
580 assert_eq!(
583 std::fs::read(&path).expect("read"),
584 before,
585 "a refusal must leave the file untouched"
586 );
587 let strays: Vec<_> = std::fs::read_dir(&dir)
588 .expect("listing")
589 .filter_map(Result::ok)
590 .filter(|e| e.file_name().to_string_lossy().contains(".torn-"))
591 .collect();
592 assert!(strays.is_empty(), "nothing was quarantined either");
593 }
594
595 #[test]
596 fn repairing_an_intact_log_does_nothing_rather_than_something() {
597 let dir = scratch("noop");
598 let path = valid_log(&dir, 3);
599 let before = std::fs::read(&path).expect("read");
600 assert!(
601 truncate_tail(&path).expect("succeeds").is_none(),
602 "an intact log has no tail to repair"
603 );
604 assert_eq!(std::fs::read(&path).expect("read"), before);
605 }
606
607 #[test]
608 fn a_renumbered_record_is_caught_even_though_it_decodes() {
609 let dir = scratch("renumber");
613 let path = valid_log(&dir, 3);
614 let text = std::fs::read_to_string(&path).expect("read");
615 let mut lines: Vec<String> = text.lines().map(ToString::to_string).collect();
616 let mut entry: serde_json::Value = serde_json::from_str(&lines[1]).expect("decodes");
617 entry["seq"] = serde_json::json!(99);
618 lines[1] = serde_json::to_string(&entry).expect("re-encode");
619 std::fs::write(&path, lines.join("\n") + "\n").expect("write back");
620
621 let report = verify(&path).expect("readable");
622 let fault = report.fault.expect("caught");
623 assert!(
624 matches!(fault, Fault::SeqMismatch { found: 99, .. }),
625 "{fault:?}"
626 );
627 }
628}