From f9da2b513b643e31c9e7a39bb425fa78969fce6b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 20:18:13 +0200 Subject: [PATCH] feat: add inspectFile and restoreFromFile to backup --- src/backup.zig | 161 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/src/backup.zig b/src/backup.zig index 6267d07..99b3f6b 100644 --- a/src/backup.zig +++ b/src/backup.zig @@ -150,3 +150,164 @@ fn parse(allocator: Allocator, data: []const u8) !ParsedBackup { var entries = std.ArrayList(ParsedEntry).init(allocator); errdefer entries.deinit(); + while (true) { + if (pos + footerMagic.len <= data.len and std.mem.eql(u8, data[pos .. pos + 4], footerMagic)) { + break; + } + if (pos + 8 > data.len) return BackupError.Truncated; + const keyLen = encode.readU32(data, pos); + const valueLen = encode.readU32(data, pos + 4); + const entryStart = pos; + const keyStart = pos + 8; + const valueStart = keyStart + keyLen; + const valueEnd = valueStart + valueLen; + if (valueEnd + 4 > data.len) return BackupError.Truncated; + + const entryBytes = data[entryStart..valueEnd]; + const storedCrc = encode.readU32(data, valueEnd); + const actualCrc = std.hash.Crc32.hash(entryBytes); + if (storedCrc != actualCrc) return BackupError.Corrupt; + + try entries.append(.{ .key = data[keyStart..valueStart], .value = data[valueStart..valueEnd] }); + pos = valueEnd + 4; + } + + if (pos + 4 + 8 + 4 > data.len) return BackupError.Truncated; + const declaredCount = encode.readU64(data, pos + 4); + if (declaredCount != entries.items.len) return BackupError.CountMismatch; + + const storedFileCrc = encode.readU32(data, pos + 4 + 8); + const actualFileCrc = std.hash.Crc32.hash(data[0..pos]); + if (storedFileCrc != actualFileCrc) return BackupError.Corrupt; + + return .{ .seq = seq, .entries = try entries.toOwnedSlice() }; +} + +/// Reads and structurally validates a backup file without applying it, +/// returning its metadata (seq, entry count). Useful to verify a backup +/// before trusting it for disaster recovery. +pub fn inspectFile(allocator: Allocator, path: []const u8) !BackupInfo { + const data = try std.fs.cwd().readFileAlloc(allocator, path, 1 << 34); + defer allocator.free(data); + const parsed = try parse(allocator, data); + defer allocator.free(parsed.entries); + return .{ .seq = parsed.seq, .entryCount = parsed.entries.len }; +} + +/// Restores every entry from the backup at `path` into `db`, in chunks of +/// `chunkSize` applied atomically via WriteBatch. The whole file is validated +/// (magics, per-entry and whole-file crc, declared count) before any chunk is +/// applied, so a corrupt backup fails without mutating the target database. +/// Existing keys in `db` outside the backup are left untouched; keys present +/// in both are overwritten with the backup's value. +pub fn restoreFromFile(allocator: Allocator, db: *Db, path: []const u8, chunkSize: usize) !BackupInfo { + const data = try std.fs.cwd().readFileAlloc(allocator, path, 1 << 34); + defer allocator.free(data); + + const parsed = try parse(allocator, data); + defer allocator.free(parsed.entries); + + var i: usize = 0; + while (i < parsed.entries.len) { + const end = @min(i + chunkSize, parsed.entries.len); + var batch = WriteBatch.init(allocator); + defer batch.deinit(); + for (parsed.entries[i..end]) |entry| { + try batch.put(entry.key, entry.value); + } + try db.apply(&batch); + i = end; + } + + return .{ .seq = parsed.seq, .entryCount = parsed.entries.len }; +} + +test "backup round trip: dump then restore into a fresh db reproduces every key" { + const allocator = std.testing.allocator; + const srcDir = "test-backup-src-1"; + const dstDir = "test-backup-dst-1"; + const backupPath = "test-backup-1.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 valBuf: [16]u8 = undefined; + var i: usize = 0; + while (i < 200) : (i += 1) { + const key = try std.fmt.bufPrint(&keyBuf, "k{d:0>6}", .{i}); + const value = try std.fmt.bufPrint(&valBuf, "v{d:0>6}", .{i}); + try src.put(key, value); + } + // overwrite one, delete another: backup must reflect resolved state. + try src.put("k000005", "updated"); + try src.delete("k000010"); + + const snap = try src.snapshot(); + const count = try dumpToFile(allocator, snap, backupPath); + try std.testing.expectEqual(@as(u64, 199), count); + + var dst = try Db.open(allocator, dstDir, .{}); + defer dst.close(); + const info = try restoreFromFile(allocator, &dst, backupPath, 32); + try std.testing.expectEqual(@as(u64, 199), info.entryCount); + + i = 0; + while (i < 200) : (i += 1) { + const key = try std.fmt.bufPrint(&keyBuf, "k{d:0>6}", .{i}); + const got = try dst.get(allocator, key); + if (i == 10) { + try std.testing.expect(got == null); + continue; + } + defer allocator.free(got.?); + if (i == 5) { + try std.testing.expectEqualStrings("updated", got.?); + } else { + const expected = try std.fmt.bufPrint(&valBuf, "v{d:0>6}", .{i}); + try std.testing.expectEqualStrings(expected, got.?); + } + } +} + +test "backup of empty db round trips to zero entries" { + const allocator = std.testing.allocator; + const srcDir = "test-backup-src-empty"; + const dstDir = "test-backup-dst-empty"; + const backupPath = "test-backup-empty.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(); + const snap = try src.snapshot(); + const count = try dumpToFile(allocator, snap, backupPath); + try std.testing.expectEqual(@as(u64, 0), count); + + const info = try inspectFile(allocator, backupPath); + try std.testing.expectEqual(@as(u64, 0), info.entryCount); + + var dst = try Db.open(allocator, dstDir, .{}); + defer dst.close(); + _ = try restoreFromFile(allocator, &dst, backupPath, 32); + var iter = try dst.scan(allocator, null, null); + defer iter.deinit(); + try std.testing.expect((try iter.next()) == null); +} + +test "backup is point-in-time: writes after snapshot are excluded" { + const allocator = std.testing.allocator; + const srcDir = "test-backup-src-pit"; + const backupPath = "test-backup-pit.cblb"; + std.fs.cwd().deleteTree(srcDir) catch {}; + defer std.fs.cwd().deleteTree(srcDir) catch {}; + defer std.fs.cwd().deleteFile(backupPath) catch {}; +