1use std::fs::{File, OpenOptions};
4use std::io;
5use std::path::Path;
6
7use crate::error::{Error, ErrorContext, Result};
8use crate::format::scan::FileScan;
9use crate::limits::Limits;
10use crate::lock::acquire_writer_lock;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum RecoveryAction {
16 None,
18 TruncateIncompleteTail,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct RecoveryPlan {
31 action: RecoveryAction,
32 file_size: u64,
33 last_good_offset: u64,
34 bytes_to_remove: u64,
35}
36
37impl RecoveryPlan {
38 pub fn action(&self) -> RecoveryAction {
40 self.action
41 }
42
43 pub fn file_size(&self) -> u64 {
45 self.file_size
46 }
47
48 pub fn last_good_offset(&self) -> u64 {
50 self.last_good_offset
51 }
52
53 pub fn bytes_to_remove(&self) -> u64 {
55 self.bytes_to_remove
56 }
57
58 pub fn requires_repair(&self) -> bool {
60 self.action == RecoveryAction::TruncateIncompleteTail
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub struct RecoverySummary {
68 original_file_size: u64,
69 repaired_file_size: u64,
70 bytes_removed: u64,
71}
72
73impl RecoverySummary {
74 pub fn original_file_size(&self) -> u64 {
76 self.original_file_size
77 }
78
79 pub fn repaired_file_size(&self) -> u64 {
81 self.repaired_file_size
82 }
83
84 pub fn bytes_removed(&self) -> u64 {
86 self.bytes_removed
87 }
88}
89
90pub fn inspect_recovery<P: AsRef<Path>>(path: P) -> Result<RecoveryPlan> {
103 inspect_recovery_with_limits(path, Limits::default())
104}
105
106pub fn inspect_recovery_with_limits<P: AsRef<Path>>(
108 path: P,
109 limits: Limits,
110) -> Result<RecoveryPlan> {
111 let scan = FileScan::open(path.as_ref(), limits)?;
112 let (_file, plan) = discover(scan)?;
113 Ok(plan)
114}
115
116pub fn repair_incomplete_tail<P: AsRef<Path>>(path: P) -> Result<RecoverySummary> {
153 repair_incomplete_tail_with_limits(path, Limits::default())
154}
155
156pub fn repair_incomplete_tail_with_limits<P: AsRef<Path>>(
158 path: P,
159 limits: Limits,
160) -> Result<RecoverySummary> {
161 let file = OpenOptions::new()
162 .read(true)
163 .write(true)
164 .open(path.as_ref())
165 .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
166 acquire_writer_lock(&file)?;
167 repair_locked_file(file, limits, &FileRepairOperations)
168}
169
170trait RepairOperations {
177 fn physical_length(&self, file: &File) -> io::Result<u64>;
178 fn set_len(&self, file: &File, length: u64) -> io::Result<()>;
179 fn sync(&self, file: &File) -> io::Result<()>;
180}
181
182struct FileRepairOperations;
183
184impl RepairOperations for FileRepairOperations {
185 fn physical_length(&self, file: &File) -> io::Result<u64> {
186 file.metadata().map(|metadata| metadata.len())
187 }
188
189 fn set_len(&self, file: &File, length: u64) -> io::Result<()> {
190 file.set_len(length)
191 }
192
193 fn sync(&self, file: &File) -> io::Result<()> {
194 file.sync_all()
195 }
196}
197
198fn discover(mut scan: FileScan) -> Result<(File, RecoveryPlan)> {
199 let walk = scan.walk_data_frames(|_frame, _block| Ok(()))?;
200 let plan = RecoveryPlan::from_walk(
201 scan.file_size(),
202 walk.last_good_offset,
203 walk.incomplete_tail,
204 )?;
205 Ok((scan.into_file(), plan))
206}
207
208fn repair_locked_file(
209 file: File,
210 limits: Limits,
211 operations: &dyn RepairOperations,
212) -> Result<RecoverySummary> {
213 let scan = FileScan::from_file(file, limits)?;
214 let (file, plan) = discover(scan)?;
215 let current_size = operations
216 .physical_length(&file)
217 .map_err(|error| Error::io(error, None).with_context(ErrorContext::File))?;
218 if current_size != plan.file_size {
219 return Err(Error::io(
220 io::Error::other("the file changed while recovery was being inspected"),
221 Some(current_size),
222 )
223 .with_context(ErrorContext::File));
224 }
225 if !plan.requires_repair() {
226 return Err(Error::invalid_argument(
227 "the file is complete; there is no incomplete tail to repair",
228 )
229 .with_context(ErrorContext::File));
230 }
231
232 operations
237 .set_len(&file, plan.last_good_offset)
238 .map_err(|error| {
239 Error::io(error, Some(plan.last_good_offset))
240 .with_message_prefix(BEFORE_TRUNCATION)
241 .with_context(ErrorContext::File)
242 })?;
243
244 finish_repair(file, limits, &plan, operations)
249 .map_err(|error| error.with_message_prefix(AFTER_TRUNCATION))
250}
251
252const BEFORE_TRUNCATION: &str = "the incomplete tail was not removed and the file is unchanged";
254
255const AFTER_TRUNCATION: &str =
257 "the incomplete tail was already removed, so the file may already be shorter than it was";
258
259fn finish_repair(
261 file: File,
262 limits: Limits,
263 plan: &RecoveryPlan,
264 operations: &dyn RepairOperations,
265) -> Result<RecoverySummary> {
266 operations.sync(&file).map_err(|error| {
267 Error::io(error, Some(plan.last_good_offset))
268 .with_message_prefix("the repaired file could not be synchronized")
269 .with_context(ErrorContext::File)
270 })?;
271
272 let post_scan = FileScan::from_file(file, limits)?;
273 let (file, post_plan) = discover(post_scan)?;
274 if post_plan.action != RecoveryAction::None
275 || post_plan.file_size != plan.last_good_offset
276 || post_plan.last_good_offset != plan.last_good_offset
277 {
278 return Err(Error::corruption(
279 "post-repair validation did not produce the expected complete file",
280 Some(plan.last_good_offset),
281 )
282 .with_context(ErrorContext::File));
283 }
284 drop(file);
285
286 let bytes_removed = plan
287 .file_size
288 .checked_sub(post_plan.file_size)
289 .ok_or_else(|| {
290 Error::corruption(
291 "repaired file grew beyond its original size",
292 Some(post_plan.file_size),
293 )
294 .with_context(ErrorContext::File)
295 })?;
296 Ok(RecoverySummary {
297 original_file_size: plan.file_size,
298 repaired_file_size: post_plan.file_size,
299 bytes_removed,
300 })
301}
302
303impl RecoveryPlan {
304 fn from_walk(file_size: u64, last_good_offset: u64, incomplete_tail: bool) -> Result<Self> {
305 let bytes_to_remove = if incomplete_tail {
306 if last_good_offset >= file_size {
307 return Err(Error::corruption(
308 "an incomplete tail does not extend beyond the last complete frame",
309 Some(last_good_offset),
310 )
311 .with_context(ErrorContext::File));
312 }
313 file_size.checked_sub(last_good_offset).ok_or_else(|| {
314 Error::corruption(
315 "the last complete frame is beyond the captured file extent",
316 Some(last_good_offset),
317 )
318 .with_context(ErrorContext::File)
319 })?
320 } else {
321 if last_good_offset != file_size {
322 return Err(Error::corruption(
323 "a complete recovery walk did not reach the file extent",
324 Some(last_good_offset),
325 )
326 .with_context(ErrorContext::File));
327 }
328 0
329 };
330 let action = if incomplete_tail {
331 RecoveryAction::TruncateIncompleteTail
332 } else {
333 RecoveryAction::None
334 };
335 Ok(Self {
336 action,
337 file_size,
338 last_good_offset,
339 bytes_to_remove,
340 })
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use std::path::PathBuf;
348 use std::sync::atomic::{AtomicU64, Ordering};
349
350 #[derive(Default)]
352 struct FailingOperations {
353 length: Option<u64>,
356 fail_length: bool,
357 fail_set_len: bool,
358 fail_sync: bool,
359 length_delta: i64,
362 }
363
364 impl RepairOperations for FailingOperations {
365 fn physical_length(&self, file: &File) -> io::Result<u64> {
366 if self.fail_length {
367 return Err(io::Error::other("injected metadata failure"));
368 }
369 match self.length {
370 Some(length) => Ok(length),
371 None => file.metadata().map(|metadata| metadata.len()),
372 }
373 }
374
375 fn set_len(&self, file: &File, length: u64) -> io::Result<()> {
376 if self.fail_set_len {
377 return Err(io::Error::other("injected set_len failure"));
378 }
379 file.set_len(length.wrapping_add(self.length_delta as u64))
380 }
381
382 fn sync(&self, file: &File) -> io::Result<()> {
383 if self.fail_sync {
384 return Err(io::Error::other("injected sync failure"));
385 }
386 file.sync_all()
387 }
388 }
389
390 #[test]
391 fn plan_requires_removal_only_for_an_incomplete_data_tail() {
392 let complete = RecoveryPlan::from_walk(10, 10, false).unwrap();
393 assert_eq!(complete.action(), RecoveryAction::None);
394 assert_eq!(complete.bytes_to_remove(), 0);
395
396 let incomplete = RecoveryPlan::from_walk(14, 10, true).unwrap();
397 assert_eq!(incomplete.action(), RecoveryAction::TruncateIncompleteTail);
398 assert!(incomplete.requires_repair());
399 assert_eq!(incomplete.bytes_to_remove(), 4);
400 }
401
402 #[test]
403 fn injected_set_len_failure_preserves_the_file() {
404 let fixture = Fixture::incomplete();
405 let error = fixture.repair(FailingOperations {
406 fail_set_len: true,
407 ..FailingOperations::default()
408 });
409 assert_eq!(error.kind(), crate::ErrorKind::Io);
410 assert!(error.message().starts_with(BEFORE_TRUNCATION), "{error}");
411 assert!(!error.message().contains(AFTER_TRUNCATION), "{error}");
412 assert_eq!(fixture.length(), fixture.original_size);
413 assert_eq!(fixture.bytes(), fixture.original_bytes);
414 }
415
416 #[test]
417 fn injected_sync_failure_does_not_claim_durability() {
418 let fixture = Fixture::incomplete();
419 let error = fixture.repair(FailingOperations {
420 fail_sync: true,
421 ..FailingOperations::default()
422 });
423 assert_eq!(error.kind(), crate::ErrorKind::Io);
424 assert!(error.message().starts_with(AFTER_TRUNCATION), "{error}");
427 assert!(!error.message().contains(BEFORE_TRUNCATION), "{error}");
428 assert!(fixture.length() < fixture.original_size);
429 }
430
431 #[test]
432 fn a_length_that_moved_under_the_plan_refuses_before_truncating() {
433 for reported in [0, 1, u64::MAX] {
434 let fixture = Fixture::incomplete();
435 let error = fixture.repair(FailingOperations {
436 length: Some(reported),
437 ..FailingOperations::default()
438 });
439 assert_eq!(error.kind(), crate::ErrorKind::Io);
440 assert!(error.message().contains("changed"), "{error}");
441 assert_eq!(fixture.bytes(), fixture.original_bytes);
442 }
443 }
444
445 #[test]
446 fn an_unreadable_length_refuses_before_truncating() {
447 let fixture = Fixture::incomplete();
448 let error = fixture.repair(FailingOperations {
449 fail_length: true,
450 ..FailingOperations::default()
451 });
452 assert_eq!(error.kind(), crate::ErrorKind::Io);
453 assert_eq!(fixture.bytes(), fixture.original_bytes);
454 }
455
456 #[test]
464 fn a_wrong_truncation_target_fails_post_repair_validation() {
465 for delta in [-64_i64, -8, -1, 1, 8, 64] {
466 let fixture = Fixture::incomplete();
467 let error = fixture.repair(FailingOperations {
468 length_delta: delta,
469 ..FailingOperations::default()
470 });
471 assert_eq!(error.kind(), crate::ErrorKind::Corruption, "delta {delta}");
472 assert!(
473 error.message().starts_with(AFTER_TRUNCATION),
474 "delta {delta}: {error}"
475 );
476 assert!(!error.message().contains(BEFORE_TRUNCATION), "{error}");
477 }
478 }
479
480 struct Fixture {
483 path: PathBuf,
484 original_bytes: Vec<u8>,
485 original_size: u64,
486 }
487
488 impl Fixture {
489 fn incomplete() -> Self {
491 static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
492
493 let mut bytes = include_bytes!("../spec/v0.2/fixtures/minimal/minimal.acta").to_vec();
494 bytes.push(0);
495 let path = std::env::temp_dir().join(format!(
496 "acta-recovery-unit-{}-{}.acta",
497 std::process::id(),
498 NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
499 ));
500 let _ = std::fs::remove_file(&path);
501 std::fs::write(&path, &bytes).unwrap();
502 Self {
503 path,
504 original_size: bytes.len() as u64,
505 original_bytes: bytes,
506 }
507 }
508
509 fn repair(&self, operations: FailingOperations) -> Error {
511 let file = OpenOptions::new()
512 .read(true)
513 .write(true)
514 .open(&self.path)
515 .unwrap();
516 acquire_writer_lock(&file).unwrap();
517 match repair_locked_file(file, Limits::default(), &operations) {
518 Ok(summary) => panic!("repair unexpectedly succeeded: {summary:?}"),
519 Err(error) => error,
520 }
521 }
522
523 fn length(&self) -> u64 {
524 std::fs::metadata(&self.path).unwrap().len()
525 }
526
527 fn bytes(&self) -> Vec<u8> {
528 std::fs::read(&self.path).unwrap()
529 }
530 }
531
532 impl Drop for Fixture {
533 fn drop(&mut self) {
534 let _ = std::fs::remove_file(&self.path);
535 }
536 }
537}