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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
//! Byte-level corruption and recovery scenarios ported from the
//! subset of RocksDB's `corruption_test.cc` that applies without
//! a fault-injecting filesystem.
//!
//! Each test writes data, closes the DB, mangles an on-disk file
//! with raw `std::fs` ops, then re-opens and asserts that the
//! engine either (a) surfaces a diagnostic error or (b) recovers
//! gracefully with the expected partial data - never silent loss
//! of earlier, uncorrupted data.
// Native-only. wasm-pack builds every test target for wasm32, and these use
// threads, the filesystem or proptest, none of which exist there. The browser
// suite lives in tests/wasm_opfs*.rs.
#![cfg(not(target_arch = "wasm32"))]
use std::fs::{self, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use regolith::{Db, Error};
use tempfile::TempDir;
mod common;
use common::{count_sst_files, count_wal_files, force_compaction, open};
// ── helpers ─────────────────────────────────────────────────────
/// Flip the byte at `offset` inside `path`.
fn flip_byte(path: &Path, offset: usize) {
let mut bytes = fs::read(path).unwrap();
bytes[offset] ^= 0xFF;
fs::write(path, &bytes).unwrap();
}
/// Truncate `path` to `new_len` bytes.
fn truncate(path: &Path, new_len: u64) {
let f = OpenOptions::new().write(true).open(path).unwrap();
f.set_len(new_len).unwrap();
}
/// Return the path of the first SST file in `<db>/sst/`.
fn first_sst(db_dir: &Path) -> PathBuf {
let sst_dir = db_dir.join("sst");
let mut entries: Vec<_> = fs::read_dir(&sst_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("sst"))
.collect();
entries.sort_by_key(|e| e.path());
entries.into_iter().next().unwrap().path()
}
/// Return the path of the first WAL file in `<db>/wal/`.
fn first_wal(db_dir: &Path) -> PathBuf {
let wal_dir = db_dir.join("wal");
let mut entries: Vec<_> = fs::read_dir(&wal_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("log"))
.collect();
entries.sort_by_key(|e| e.path());
entries.into_iter().next().unwrap().path()
}
fn assert_open_fails_with_kind(dir: &TempDir, expected: io::ErrorKind) {
match Db::open(dir.path(), Default::default()) {
Err(Error::Corruption(e)) => assert_eq!(e.kind(), expected),
Err(e) => panic!("expected corruption error, got {e:?}"),
Ok(_) => panic!("expected DB open to fail"),
}
}
// ── WAL tail corruption ─────────────────────────────────────────
#[test]
fn torn_wal_tail_checksum_flip_fails_open_and_keeps_wal() {
// A checksum mismatch means replay cannot prove which committed
// records are safe. Open must fail closed and leave the WAL for
// repair/inspection rather than silently keeping only a prefix.
let dir = TempDir::new().unwrap();
{
let db = open(&dir);
db.put(b"good_1", b"1").unwrap();
db.put(b"good_2", b"2").unwrap();
}
let wal = first_wal(dir.path());
let wal_count = count_wal_files(dir.path());
let size = fs::metadata(&wal).unwrap().len() as usize;
// Flip the last byte of the file - always part of the trailing
// record's 4-byte checksum.
flip_byte(&wal, size - 1);
assert_open_fails_with_kind(&dir, io::ErrorKind::InvalidData);
assert!(wal.exists());
assert_eq!(count_wal_files(dir.path()), wal_count);
}
#[test]
fn wal_truncated_at_arbitrary_offset_replays_the_whole_records_before_the_cut() {
// Truncation is what a crash leaves behind: the records before the
// cut are byte-for-byte what the process wrote, and only the one the
// cut lands in is incomplete. Every cut below lands inside the second
// record, so the first must always come back and the second never
// may. Failing the open here instead would throw away a write that
// was acknowledged and fsynced.
for trim in [1u64, 3, 5, 9, 11, 15, 20] {
let dir = TempDir::new().unwrap();
{
let db = open(&dir);
db.put(b"a", b"1").unwrap();
db.put(b"b", b"2").unwrap();
}
let wal = first_wal(dir.path());
let wal_count = count_wal_files(dir.path());
let full = fs::read(&wal).unwrap();
// `[stamp: 12][len: u32 LE][type: u8][payload][crc: u32 LE]`,
// so this is where the first record ends. The length is read
// after the stamp, not at byte zero: reading it at zero takes
// four bytes of the REGO stamp as a length and lands the cut
// somewhere arbitrary. Matches `WAL_STAMP_LEN` in
// `src/engine/wal.rs`.
const STAMP: u64 = 12;
let len =
u32::from_le_bytes(full[STAMP as usize..STAMP as usize + 4].try_into().unwrap()) as u64;
let first_end = STAMP + 9 + len;
let cut = full.len() as u64 - trim;
assert!(
cut > first_end && cut < full.len() as u64,
"a cut of {trim} must land inside the second record",
);
truncate(&wal, cut);
let db = Db::open(dir.path(), Default::default())
.unwrap_or_else(|e| panic!("cut at {cut}: {e}"));
assert_eq!(db.get(b"a").unwrap(), Some(b"1".to_vec()), "cut at {cut}");
assert_eq!(db.get(b"b").unwrap(), None, "cut at {cut}");
db.close().unwrap();
assert_eq!(count_wal_files(dir.path()), wal_count);
}
}
#[test]
fn wal_checksum_flip_in_final_record_fails_open() {
// Flipping a checksum byte in the last record is still a
// corruption signal. Do not convert it into a clean stop.
let dir = TempDir::new().unwrap();
{
let db = open(&dir);
db.put(b"first", b"v1").unwrap();
db.put(b"second", b"v2").unwrap();
}
let wal = first_wal(dir.path());
let size = fs::metadata(&wal).unwrap().len() as usize;
// Flip the very last byte (high byte of the trailing checksum).
flip_byte(&wal, size - 1);
assert_open_fails_with_kind(&dir, io::ErrorKind::InvalidData);
assert!(wal.exists());
}
// ── manifest corruption ─────────────────────────────────────────
#[test]
fn manifest_deleted_prevents_reopen_of_nonempty_db() {
// corruption_test.cc::MissingDescriptor - once a DB has written
// SSTables, deleting the manifest drops the pointer to them.
// Opening without a manifest yields a fresh-looking DB (the
// manifest is re-created empty), so the pre-existing files are
// orphaned but the open must not panic.
let dir = TempDir::new().unwrap();
{
let db = open(&dir);
for i in 0..200 {
db.put(format!("k_{:04}", i).as_bytes(), &[0u8; 64])
.unwrap();
}
force_compaction(&db);
}
let manifest = dir.path().join("MANIFEST");
assert!(manifest.exists());
fs::remove_file(&manifest).unwrap();
// Expected behavior: open succeeds and produces an empty view
// of the DB. The orphaned SST files are still on disk but not
// referenced.
let db = open(&dir);
assert!(db.scan(None, None).unwrap().is_empty());
// At least one SST file is still physically present.
assert!(count_sst_files(dir.path()) >= 1);
}
#[test]
fn corrupted_manifest_record_stops_replay_but_opens() {
// corruption_test.cc::CorruptedDescriptor - a mid-file bad
// checksum in the manifest halts replay at the corruption but
// leaves the pre-corruption state intact.
let dir = TempDir::new().unwrap();
{
let db = open(&dir);
db.put(b"k1", b"v1").unwrap();
force_compaction(&db);
db.put(b"k2", b"v2").unwrap();
force_compaction(&db);
}
// Flip a byte deep in the manifest - lands inside the second
// record's payload or checksum, which the replay loop treats
// as "stop here".
let manifest = dir.path().join("MANIFEST");
let size = fs::metadata(&manifest).unwrap().len() as usize;
flip_byte(&manifest, size - 6);
// Open must succeed; the state visible is a prefix of what was
// committed before the corruption, so at least `k1` should be
// readable. The exact cutoff depends on where the flip landed.
let db = open(&dir);
let _ = db.get(b"k1");
let _ = db.get(b"k2");
}
// ── SSTable corruption ──────────────────────────────────────────
#[test]
fn truncated_sst_file_to_below_footer_reports_error_on_open() {
// corruption_test.cc::CorruptedBlock - an SSTable smaller than
// its 64-byte footer cannot be opened. The engine must
// surface the error rather than silently drop the file.
let dir = TempDir::new().unwrap();
{
let db = open(&dir);
for i in 0..100 {
db.put(format!("k_{:04}", i).as_bytes(), b"v").unwrap();
}
force_compaction(&db);
}
let sst = first_sst(dir.path());
truncate(&sst, 10);
// Reopening must either error cleanly or surface a first read
// error; it must not panic.
if let Ok(db) = Db::open(dir.path(), Default::default()) {
// If open succeeded, at least trying to read the
// corrupted key should return an Err rather than wrong
// data or a panic.
let _ = db.get(b"k_0000");
}
}
#[test]
fn sst_footer_magic_byte_flip_detected_on_open() {
// corruption_test.cc::CorruptedBlock - the last 8 bytes of the
// footer carry the magic number; flipping one byte must make
// the engine refuse to trust the file.
let dir = TempDir::new().unwrap();
{
let db = open(&dir);
for i in 0..50 {
db.put(format!("k_{:02}", i).as_bytes(), b"v").unwrap();
}
force_compaction(&db);
}
let sst = first_sst(dir.path());
let size = fs::metadata(&sst).unwrap().len() as usize;
flip_byte(&sst, size - 1); // high byte of magic
// Open attempt: we accept either Err OR Ok that errors on read.
if let Ok(db) = Db::open(dir.path(), Default::default()) {
// If open tolerates the file, the first read of a key
// inside that file must either error or return None -
// crucially, it must not panic.
let _ = db.get(b"k_00");
}
}
#[test]
fn stray_file_in_sst_dir_does_not_break_open() {
// Not a direct corruption_test.cc scenario, but a robustness
// check: leftover non-SST files in the SST directory (partial
// compaction temp files, editor swap files) should be ignored
// rather than crash the open path.
let dir = TempDir::new().unwrap();
{
let db = open(&dir);
db.put(b"k", b"v").unwrap();
force_compaction(&db);
}
let sst_dir = dir.path().join("sst");
fs::write(sst_dir.join("leftover.tmp"), b"junk").unwrap();
fs::write(sst_dir.join(".hidden"), b"junk").unwrap();
let db = open(&dir);
assert_eq!(db.get(b"k").unwrap(), Some(b"v".to_vec()));
}
// ── positive invariants ─────────────────────────────────────────
#[test]
fn clean_close_then_reopen_has_stable_file_count() {
// Control case: when nothing is corrupted, reopening should
// produce exactly the same on-disk layout.
let dir = TempDir::new().unwrap();
{
let db = open(&dir);
for i in 0..300 {
db.put(format!("k_{:04}", i).as_bytes(), b"v").unwrap();
}
force_compaction(&db);
}
let sst_before = count_sst_files(dir.path());
let wal_before = count_wal_files(dir.path());
{
let _db = open(&dir);
}
let sst_after = count_sst_files(dir.path());
let wal_after = count_wal_files(dir.path());
// Reopen creates a fresh WAL but shouldn't delete SSTs.
assert_eq!(sst_before, sst_after);
assert!(wal_after >= wal_before.saturating_sub(1));
}