From a5ba9ed3db3f683bd54396c7bbe9762e4acd8b5f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 20:21:38 +0200 Subject: [PATCH] feat: extend backup --- src/backup.zig | 214 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/src/backup.zig b/src/backup.zig index 99b3f6b..d43a934 100644 --- a/src/backup.zig +++ b/src/backup.zig @@ -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); +}