1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
//! What:
//! The shadow repository's `packed-refs` damaged the way a power loss can damage a file that was written and never
//! synced, at every phase a killed commit reaches, and the outcome of the next wrapped commands.
//! Why:
//! The snapshot of the real refs inside the throwaway shadow is written without a sync on the files ref backend.
//! The sweep of `binary_recovery_tearing_tests.rs` walks the journal, the capture store and the locks, not the
//! shadow, so this module adds the cases that decide whether that write may stay unsynced: recovery must reach the
//! same outcome as the undamaged commit whether the file is emptied, cut to half, zero-filled to its length, or gone.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! for (const damage of ['empty', 'half', 'zeros', 'delete']) {
//! const paused = await pauseAt(phase); await damageFile(shadowPackedRefs(paused), damage);
//! expect(await classify(paused)).toBe(recorded[`${phase}|(undamaged)|none`]);
//! }
//! ```
use super::{Damage, Paused, apply, classify, expected, pause_at};
use crate::commit::remove_settled;
use std::path::{Path, PathBuf};
/// The `packed-refs` file of every shadow repository under the Git directory.
fn shadow_packed_refs(git_dir: &Path) -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = std::fs::read_dir(git_dir.join("cli-git/shadow"))
.map(|entries| {
return entries
.flatten()
.map(|entry| { return entry.path().join("packed-refs"); })
.filter(|path| { return path.is_file(); })
.collect();
})
.unwrap_or_default();
found.sort();
return found;
}
/// What:
/// Fill the file with zero bytes up to its length, the other state an unsynced extending write can leave.
/// Why:
/// A filesystem that persists a file's size before its data shows zeros, which is neither emptied nor halved.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await writeFile(file, Buffer.alloc((await stat(file)).size));
/// ```
fn zero_fill(file: &Path) {
let length: usize = usize::try_from(std::fs::metadata(file).expect("the file to damage").len()).expect("length fits");
assert!(length > 0, "{} holds the snapshot header at least", file.display());
std::fs::write(file, vec![0_u8; length]).expect("zero fill");
}
/// What:
/// The outcome the undamaged commit has at a phase, from the recorded table.
/// Why:
/// A damaged shadow must change nothing, so the recorded control is the expected cell of every case.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// recorded[`${phase}|(undamaged)|none`]
/// ```
fn recorded_control(phase: &str) -> &'static str {
let key: String = format!("{phase}|(undamaged)|none");
return expected::OUTCOMES
.iter()
.find(|(name, _)| { return *name == key; })
.map(|(_, outcome)| { return *outcome; })
.unwrap_or_else(|| { panic!("the table records {key}"); });
}
/// What:
/// Kill a commit at `phase`, damage the shadow's `packed-refs` four ways in turn, and require the recorded control
/// outcome after each.
/// Why:
/// Each damage starts from a fresh killed commit so no case sees another's recovery.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// for (const damage of damages) expect(await classify(await damaged(phase, damage))).toBe(control(phase));
/// ```
fn shadow_refs_do_not_change_the_outcome(phase: &str) {
let control: &str = recorded_control(phase);
let names: [&str; 4] = ["empty", "half", "zeros", "delete"];
for name in names {
let paused: Paused = pause_at(phase);
let git_dir: PathBuf = paused.repo.join(".git");
let files: Vec<PathBuf> = shadow_packed_refs(git_dir.as_path());
assert_eq!(files.len(), 1, "exactly one shadow holds packed-refs at {phase}: {files:?}");
match name {
"zeros" => zero_fill(files[0].as_path()),
"empty" => assert!(apply(Damage::Empty, files[0].as_path())),
"half" => assert!(apply(Damage::Half, files[0].as_path())),
_ => assert!(apply(Damage::Dropped, files[0].as_path())),
}
let (cell, note): (String, String) = classify(&paused);
remove_settled(&paused.fixture);
assert_eq!(cell, control, "{phase}: shadow packed-refs {name}: {note}");
}
}
/// A commit killed with its capture lock held; the shadow and its snapshot exist already.
#[test]
fn shadow_refs_at_capture_locked() {
shadow_refs_do_not_change_the_outcome("capture-locked");
}
/// A commit killed after preparation.
#[test]
fn shadow_refs_at_preparation_done() {
shadow_refs_do_not_change_the_outcome("preparation-done");
}
/// A commit killed with the landing and index locks held.
#[test]
fn shadow_refs_at_landing_locked() {
shadow_refs_do_not_change_the_outcome("landing-locked");
}
/// A commit killed after the objects migrated into the kept pack.
#[test]
fn shadow_refs_at_objects_migrated() {
shadow_refs_do_not_change_the_outcome("objects-migrated");
}
/// A commit killed after the branch advanced.
#[test]
fn shadow_refs_at_ref_updated() {
shadow_refs_do_not_change_the_outcome("ref-updated");
}
/// A commit killed after the index was installed.
#[test]
fn shadow_refs_at_index_installed() {
shadow_refs_do_not_change_the_outcome("index-installed");
}