feat: extend backup

This commit is contained in:
dualfroz committed 2026-09-01 20:21:38 +02:00
1 parent f9da2b513b
commit a5ba9ed3db
1 file changed
+214
+214
View File
@@ -311,3 +311,217 @@ test "backup is point-in-time: writes after snapshot are excluded" {
defer std.fs.cwd().deleteTree(srcDir) catch {};
defer std.fs.cwd().deleteFile(backupPath) catch {};
var src = try Db.open(allocator, srcDir, .{});
defer src.close();
try src.put("before", "1");
const snap = try src.snapshot();
try src.put("after", "2");
const count = try dumpToFile(allocator, snap, backupPath);
try std.testing.expectEqual(@as(u64, 1), count);
const info = try inspectFile(allocator, backupPath);
try std.testing.expectEqual(snap.seq, info.seq);
}
test "restore into db with pre-existing disjoint keys merges without touching them" {
const allocator = std.testing.allocator;
const srcDir = "test-backup-src-merge";
const dstDir = "test-backup-dst-merge";
const backupPath = "test-backup-merge.cblb";
std.fs.cwd().deleteTree(srcDir) catch {};
std.fs.cwd().deleteTree(dstDir) catch {};
defer std.fs.cwd().deleteTree(srcDir) catch {};
defer std.fs.cwd().deleteTree(dstDir) catch {};
defer std.fs.cwd().deleteFile(backupPath) catch {};
var src = try Db.open(allocator, srcDir, .{});
defer src.close();
try src.put("from-backup", "yes");
const count = try dumpToFile(allocator, try src.snapshot(), backupPath);
try std.testing.expectEqual(@as(u64, 1), count);
var dst = try Db.open(allocator, dstDir, .{});
defer dst.close();
try dst.put("already-here", "kept");
_ = try restoreFromFile(allocator, &dst, backupPath, 32);
const kept = try dst.get(allocator, "already-here");
defer allocator.free(kept.?);
try std.testing.expectEqualStrings("kept", kept.?);
const restored = try dst.get(allocator, "from-backup");
defer allocator.free(restored.?);
try std.testing.expectEqualStrings("yes", restored.?);
}
test "restore chunking applies every entry across multiple write batches" {
const allocator = std.testing.allocator;
const srcDir = "test-backup-src-chunks";
const dstDir = "test-backup-dst-chunks";
const backupPath = "test-backup-chunks.cblb";
std.fs.cwd().deleteTree(srcDir) catch {};
std.fs.cwd().deleteTree(dstDir) catch {};
defer std.fs.cwd().deleteTree(srcDir) catch {};
defer std.fs.cwd().deleteTree(dstDir) catch {};
defer std.fs.cwd().deleteFile(backupPath) catch {};
var src = try Db.open(allocator, srcDir, .{});
defer src.close();
var keyBuf: [16]u8 = undefined;
var i: usize = 0;
while (i < 97) : (i += 1) {
const key = try std.fmt.bufPrint(&keyBuf, "c{d:0>6}", .{i});
try src.put(key, "x");
}
_ = try dumpToFile(allocator, try src.snapshot(), backupPath);
var dst = try Db.open(allocator, dstDir, .{});
defer dst.close();
const info = try restoreFromFile(allocator, &dst, backupPath, 10);
try std.testing.expectEqual(@as(u64, 97), info.entryCount);
var found: usize = 0;
var iter = try dst.scan(allocator, null, null);
defer iter.deinit();
while (try iter.next()) |_| found += 1;
try std.testing.expectEqual(@as(usize, 97), found);
}
test "corrupted entry payload is rejected before any restore write happens" {
const allocator = std.testing.allocator;
const srcDir = "test-backup-src-corrupt";
const dstDir = "test-backup-dst-corrupt";
const backupPath = "test-backup-corrupt.cblb";
std.fs.cwd().deleteTree(srcDir) catch {};
std.fs.cwd().deleteTree(dstDir) catch {};
defer std.fs.cwd().deleteTree(srcDir) catch {};
defer std.fs.cwd().deleteTree(dstDir) catch {};
defer std.fs.cwd().deleteFile(backupPath) catch {};
var src = try Db.open(allocator, srcDir, .{});
defer src.close();
try src.put("intact", "value-one");
try src.put("victim", "value-two");
_ = try dumpToFile(allocator, try src.snapshot(), backupPath);
{
const file = try std.fs.cwd().openFile(backupPath, .{ .mode = .read_write });
defer file.close();
// flip a byte squarely inside the entry region (well past the 16 byte header).
try file.seekTo(24);
var byte: [1]u8 = undefined;
_ = try file.read(&byte);
try file.seekTo(24);
try file.writeAll(&.{byte[0] ^ 0xFF});
}
var dst = try Db.open(allocator, dstDir, .{});
defer dst.close();
const result = restoreFromFile(allocator, &dst, backupPath, 32);
try std.testing.expectError(BackupError.Corrupt, result);
// nothing from the corrupt backup should have been applied: validation
// happens over the whole in-memory buffer before the first WriteBatch.
var iter = try dst.scan(allocator, null, null);
defer iter.deinit();
try std.testing.expect((try iter.next()) == null);
}
test "truncated file is rejected as Truncated, not a crash" {
const allocator = std.testing.allocator;
const srcDir = "test-backup-src-trunc";
const dstDir = "test-backup-dst-trunc";
const backupPath = "test-backup-trunc.cblb";
std.fs.cwd().deleteTree(srcDir) catch {};
std.fs.cwd().deleteTree(dstDir) catch {};
defer std.fs.cwd().deleteTree(srcDir) catch {};
defer std.fs.cwd().deleteTree(dstDir) catch {};
defer std.fs.cwd().deleteFile(backupPath) catch {};
var src = try Db.open(allocator, srcDir, .{});
defer src.close();
try src.put("a", "b");
_ = try dumpToFile(allocator, try src.snapshot(), backupPath);
{
const data = try std.fs.cwd().readFileAlloc(allocator, backupPath, 1 << 20);
defer allocator.free(data);
const truncated = data[0 .. data.len - 5];
const file = try std.fs.cwd().createFile(backupPath, .{ .truncate = true });
defer file.close();
try file.writeAll(truncated);
}
var dst = try Db.open(allocator, dstDir, .{});
defer dst.close();
const result = restoreFromFile(allocator, &dst, backupPath, 32);
try std.testing.expectError(BackupError.Truncated, result);
}
test "bad magic header is rejected without reading the rest of the file" {
const allocator = std.testing.allocator;
const backupPath = "test-backup-badmagic.cblb";
defer std.fs.cwd().deleteFile(backupPath) catch {};
const file = try std.fs.cwd().createFile(backupPath, .{ .truncate = true });
{
defer file.close();
try file.writeAll("XXXX" ++ [_]u8{0} ** 12);
}
const result = inspectFile(allocator, backupPath);
try std.testing.expectError(BackupError.BadMagic, result);
}
test "unsupported version is rejected" {
const allocator = std.testing.allocator;
const backupPath = "test-backup-badversion.cblb";
defer std.fs.cwd().deleteFile(backupPath) catch {};
const file = try std.fs.cwd().createFile(backupPath, .{ .truncate = true });
{
defer file.close();
var buf: [16]u8 = undefined;
@memcpy(buf[0..4], headerMagic);
std.mem.writeInt(u32, buf[4..8], 99, encode.endian);
std.mem.writeInt(u64, buf[8..16], 0, encode.endian);
try file.writeAll(&buf);
}
const result = inspectFile(allocator, backupPath);
try std.testing.expectError(BackupError.UnsupportedVersion, result);
}
test "declared entry count mismatch is caught even if per-entry crcs are valid" {
const allocator = std.testing.allocator;
const srcDir = "test-backup-src-countmismatch";
const backupPath = "test-backup-countmismatch.cblb";
std.fs.cwd().deleteTree(srcDir) catch {};
defer std.fs.cwd().deleteTree(srcDir) catch {};
defer std.fs.cwd().deleteFile(backupPath) catch {};
var src = try Db.open(allocator, srcDir, .{});
defer src.close();
try src.put("a", "1");
try src.put("b", "2");
_ = try dumpToFile(allocator, try src.snapshot(), backupPath);
{
const data = try std.fs.cwd().readFileAlloc(allocator, backupPath, 1 << 20);
defer allocator.free(data);
// footer is the last 4(magic)+8(count)+4(crc)=16 bytes; corrupt count only,
// which necessarily also breaks the whole-file crc, but count is checked first.
const countPos = data.len - 12;
var mutated = try allocator.dupe(u8, data);
defer allocator.free(mutated);
std.mem.writeInt(u64, mutated[countPos..][0..8], 999, encode.endian);
const file = try std.fs.cwd().createFile(backupPath, .{ .truncate = true });
defer file.close();
try file.writeAll(mutated);
}
const result = inspectFile(allocator, backupPath);
try std.testing.expectError(BackupError.CountMismatch, result);
}