//! The blame cache: per file, the line attribution of a few versions that
//! `zit annotate` has already walked, so annotating a descendant walks only
//! the versions since. Attribution is a pure function of a version's
//! ancestry, so a cached version stands in for all of it (`annotate.planCut`).
//!
//! What is kept per (path, check-in) is each line's origin: an index into the
//! entry's table of distinct origin check-ins, each named by its HASH (rids
//! are renumbered by a pack or a rebuild) with its committing user, so the
//! renderer need not parse a manifest per origin. Content is never kept: the
//! reader re-reads the version from the store, hash-checked, and refuses the
//! entry when its line count differs.
//!
//! Advisory like every `.zit-cache` member: any failure is a miss, deleting
//! it can only cost time, and the next write replaces a damaged file. Keyed
//! not by the log but by `fingerprint`: a clean full-ancestry attribution
//! depends only on content-addressed artifacts and on what this store says is
//! absent (shunned, purged, outside its cone or horizon), so the log growing
//! must not invalidate it, while any change to those does.
//!
//! Files are immutable once written. A writer builds a new file and renames
//! it over the old one; readers map whichever file they opened. Two racing
//! writers each replace the file whole, so one entry is lost, never mixed.
//!
//! Layout (little-endian), header then body then the body's block CRC table
//! (`blockcrc`):
//!
//! header: magic | version u16 | fingerprint u32 | n_entries u16
//! | body_len u32 | table_crc u32 | path_len u16 | path
//! | n_entries × (hash_len u8 | hash, zero-padded to 64
//! | line_count u32 | at u32 | len u32)
//! | header_crc u32
//! entry: n_origins u32 | lines_at u32 | n_origins × offset u32
//! | (hash_len u8 | hash | has_user u8 | user_len u16 | user)*
//! | line_count × origin index u16 (at lines_at)
//!
//! Each entry starts on a block boundary and is padded to one, so a writer
//! carries an older entry over by copying its blocks and their CRCs as they
//! are. A damaged block stays damaged under its old CRC, never laundered
//! under a fresh one; the read that finds it is a miss, and the write that
//! follows that read starts a fresh file.
const std = @import("std");
const crc32 = @import("crc32.zig");
const blockcrc = @import("blockcrc.zig");
const repocache = @import("repocache.zig");
const thinned = @import("thinned.zig");
const magic = "ZITBLAM\x00";
/// Bump on any change to the layout or to what annotate attributes: an
/// entry written under another version is a miss.
pub const version: u16 = 2;
/// Entries kept per file, newest first.
pub const max_entries: usize = 8;
/// A file past this is not written; the walk it would save is paid instead.
pub const max_file_bytes: usize = 4 << 20;
/// Origin indices are u16; a version with more distinct origins is not kept.
pub const max_origins: usize = std.math.maxInt(u16);
pub const dirname: []const u8 = repocache.cache_dirname ++ "/blame";
const fixed_len = 8 + 2 + 4 + 2 + 4 + 4 + 2; // = 26, through path_len
const slot_len = 1 + 64 + 4 + 4 + 4; // = 77
const path_len_at = 24;
/// One origin check-in: its hash and its U-card login (null when the
/// manifest carries none).
pub const Origin = struct { hash: []const u8, user: ?[]const u8 };
/// One version's attribution, as written: `lines[i]` indexes `origins`.
pub const Entry = struct {
checkin: []const u8,
origins: []const Origin,
lines: []const u16,
};
pub const ReadError = error{ OutOfMemory, Corrupt };
/// The member a path's cache lives in: `dirname`/hex(wyhash(path)). The
/// full path is in the header, so a collision reads as a miss.
pub fn memberPath(buf: *[dirname.len + 1 + 16]u8, path: []const u8) []const u8 {
const h = std.hash.Wyhash.hash(0, path);
return std.fmt.bufPrint(buf, "{s}/{x:0>16}", .{ dirname, h }) catch unreachable;
}
/// What the store says is absent, folded into one value: the shun table's
/// fingerprint, the thinned list, the store cone and the horizon seed. Null
/// when one of them exists but cannot be read, so nothing is cached against
/// an answer this store cannot vouch for.
pub fn fingerprint(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, shun: u32) ?u32 {
var crc = crc32.Crc32.init();
crc.update(std.mem.asBytes(&std.mem.nativeToLittle(u32, shun)));
for ([_][]const u8{ thinned.filename, repocache.store_cone_filename, repocache.horizon_seed_filename }) |name| {
const bytes = dir.readFileAlloc(io, name, gpa, .limited(thinned.max_names * 65 + 1)) catch |e| switch (e) {
error.FileNotFound => {
crc.update("\x00");
continue;
},
else => return null,
};
defer gpa.free(bytes);
crc.update("\x01");
const part = crc32.Crc32.hash(bytes);
crc.update(std.mem.asBytes(&std.mem.nativeToLittle(u32, part)));
}
return crc.final();
}
/// Where a file's body starts, read from its header without checking it:
/// tests use it to damage one block at a time.
pub fn bodyStart(bytes: []const u8) usize {
return fixed_len + @as(usize, readInt(u16, bytes, path_len_at)) + @as(usize, readInt(u16, bytes, 14)) * slot_len + 4;
}
/// A parsed cache file over bytes it borrows. The header and the block
/// table are checked at parse; an entry's blocks as it is read.
pub const File = struct {
bytes: []const u8,
verifier: blockcrc.Verifier,
n_entries: u16,
/// Where the directory slots start, in `bytes`.
slots_at: usize,
/// Null unless magic, version, header CRC, block table CRC, `path`
/// and `fp` all hold and the parts fill the file exactly.
pub fn parse(gpa: std.mem.Allocator, bytes: []const u8, path: []const u8, fp: u32) error{OutOfMemory}!?File {
if (bytes.len < fixed_len + 4 or bytes.len > max_file_bytes) return null;
if (!std.mem.eql(u8, bytes[0..8], magic)) return null;
if (readInt(u16, bytes, 8) != version) return null;
const path_len = readInt(u16, bytes, path_len_at);
const n_entries = readInt(u16, bytes, 14);
if (n_entries > max_entries) return null;
const header_len = fixed_len + @as(usize, path_len) + @as(usize, n_entries) * slot_len + 4;
if (header_len > bytes.len) return null;
if (crc32.Crc32.hash(bytes[0 .. header_len - 4]) != readInt(u32, bytes, header_len - 4)) return null;
if (!std.mem.eql(u8, bytes[fixed_len..][0..path_len], path)) return null;
if (readInt(u32, bytes, 10) != fp) return null;
const body_len: usize = readInt(u32, bytes, 16);
// Entries are whole blocks, and `encode` copies them so.
if (body_len > bytes.len - header_len or body_len % blockcrc.block_len != 0) return null;
const body = bytes[header_len..][0..body_len];
const table = bytes[header_len + body_len ..];
if (table.len != blockcrc.tableLen(body_len)) return null;
if (crc32.Crc32.hash(table) != readInt(u32, bytes, 20)) return null;
const slots_at = fixed_len + @as(usize, path_len);
for (0..n_entries) |k| {
const s = slotAt(bytes, slots_at, k);
if (s.hash.len != 40 and s.hash.len != 64) return null;
if (s.at % blockcrc.block_len != 0 or s.len > body_len or s.at > body_len - s.len) return null;
}
return .{
.bytes = bytes,
.verifier = try blockcrc.Verifier.init(gpa, body, table),
.n_entries = n_entries,
.slots_at = slots_at,
};
}
pub fn deinit(self: *File, gpa: std.mem.Allocator) void {
self.verifier.deinit(gpa);
self.* = undefined;
}
/// The slot holding `checkin`'s entry, or null.
pub fn find(self: *const File, checkin: []const u8) ?usize {
for (0..self.n_entries) |k| {
if (std.mem.eql(u8, slotAt(self.bytes, self.slots_at, k).hash, checkin)) return k;
}
return null;
}
pub fn lineCount(self: *const File, slot: usize) u32 {
return slotAt(self.bytes, self.slots_at, slot).line_count;
}
/// Entry `slot`, its blocks checked as they are read. A failed CRC or a
/// record pointing outside the entry is `Corrupt`, and every later read
/// fails too: the file is no longer trusted.
pub fn read(self: *File, gpa: std.mem.Allocator, slot: usize) ReadError!View {
std.debug.assert(slot < self.n_entries);
const s = slotAt(self.bytes, self.slots_at, slot);
var r: Reader = .{ .file = self, .base = s.at, .len = s.len };
const n_origins = try r.int(u32, 0);
if (n_origins > max_origins) return error.Corrupt;
const lines_at = try r.int(u32, 4);
const lines = try r.span(lines_at, @as(usize, s.line_count) * 2);
const origins = try gpa.alloc(Origin, n_origins);
errdefer gpa.free(origins);
for (origins, 0..) |*o, i| {
const at = try r.int(u32, 8 + i * 4);
const hash_len = (try r.span(at, 1))[0];
if (hash_len != 40 and hash_len != 64) return error.Corrupt;
o.hash = try r.span(at + 1, hash_len);
const has_user = (try r.span(at + 1 + hash_len, 1))[0];
const user_len = try r.int(u16, at + 2 + hash_len);
const user = try r.span(at + 4 + hash_len, user_len);
o.user = switch (has_user) {
0 => if (user_len == 0) null else return error.Corrupt,
1 => user,
else => return error.Corrupt,
};
}
var i: usize = 0;
while (i < s.line_count) : (i += 1) {
if (readInt(u16, lines, i * 2) >= n_origins) return error.Corrupt;
}
return .{ .origins = origins, .lines = lines };
}
};
/// One entry as read: `lineAt(i)` indexes `origins`, whose strings borrow
/// the file's bytes.
pub const View = struct {
origins: []Origin,
lines: []const u8,
pub fn lineAt(self: *const View, i: usize) u16 {
return readInt(u16, self.lines, i * 2);
}
pub fn deinit(self: *View, gpa: std.mem.Allocator) void {
gpa.free(self.origins);
self.* = undefined;
}
};
const Slot = struct { hash: []const u8, line_count: u32, at: usize, len: usize };
fn slotAt(bytes: []const u8, slots_at: usize, k: usize) Slot {
const at = slots_at + k * slot_len;
const hash_len = @min(bytes[at], 64);
return .{
.hash = bytes[at + 1 ..][0..hash_len],
.line_count = readInt(u32, bytes, at + 65),
.at = readInt(u32, bytes, at + 69),
.len = readInt(u32, bytes, at + 73),
};
}
/// Entry-relative reads, checked against the entry's bounds and blocks.
const Reader = struct {
file: *File,
base: usize,
len: usize,
fn span(self: *Reader, at: usize, n: usize) error{Corrupt}![]const u8 {
if (at > self.len or n > self.len - at) return error.Corrupt;
if (!self.file.verifier.check(self.base + at, n)) return error.Corrupt;
return self.file.verifier.body[self.base + at ..][0..n];
}
fn int(self: *Reader, comptime T: type, at: usize) error{Corrupt}!T {
return readInt(T, try self.span(at, @sizeOf(T)), 0);
}
};
fn readInt(comptime T: type, bytes: []const u8, at: usize) T {
return std.mem.readInt(T, bytes[at..][0..@sizeOf(T)], .little);
}
fn writeInt(comptime T: type, bytes: []u8, at: usize, v: T) void {
std.mem.writeInt(T, bytes[at..][0..@sizeOf(T)], v, .little);
}
fn padded(n: usize) usize {
return std.mem.alignForward(usize, n, blockcrc.block_len);
}
fn entryLen(e: Entry) usize {
var n: usize = 8 + e.origins.len * 4;
for (e.origins) |o| n += 1 + o.hash.len + 1 + 2 + if (o.user) |u| u.len else 0;
return n + e.lines.len * 2;
}
/// A file holding `new` first, then `prior`'s entries newest first (but
/// `new`'s check-in), as many as `max_entries` and `max_file_bytes` allow.
/// Null when `new` alone cannot be kept: too many origins, a name or user
/// too long, or past the size bound. Caller frees.
pub fn encode(gpa: std.mem.Allocator, path: []const u8, fp: u32, new: Entry, prior: ?*const File) error{OutOfMemory}!?[]u8 {
if (path.len > std.math.maxInt(u16) or new.origins.len > max_origins) return null;
if (new.checkin.len != 40 and new.checkin.len != 64) return null;
if (new.lines.len > std.math.maxInt(u32)) return null;
for (new.origins) |o| {
if (o.hash.len != 40 and o.hash.len != 64) return null;
if (o.user) |u| if (u.len > std.math.maxInt(u16)) return null;
}
const new_len = entryLen(new);
// Which prior entries ride along, newest first, within both bounds.
var keep: [max_entries]usize = undefined;
var n_keep: usize = 0;
var body_len = padded(new_len);
var header_len = fixed_len + path.len + slot_len + 4;
if (header_len + body_len + blockcrc.tableLen(body_len) > max_file_bytes) return null;
if (prior) |p| for (0..p.n_entries) |k| {
if (n_keep + 1 == max_entries) break;
const s = slotAt(p.bytes, p.slots_at, k);
if (std.mem.eql(u8, s.hash, new.checkin)) continue;
const grown = body_len + padded(s.len);
if (header_len + slot_len + grown + blockcrc.tableLen(grown) > max_file_bytes) break;
keep[n_keep] = k;
n_keep += 1;
body_len = grown;
header_len += slot_len;
};
if (body_len > std.math.maxInt(u32)) return null;
const blob = try gpa.alloc(u8, header_len + body_len + blockcrc.tableLen(body_len));
errdefer gpa.free(blob);
@memset(blob, 0);
@memcpy(blob[0..8], magic);
writeInt(u16, blob, 8, version);
writeInt(u32, blob, 10, fp);
writeInt(u16, blob, 14, @intCast(1 + n_keep));
writeInt(u32, blob, 16, @intCast(body_len));
writeInt(u16, blob, path_len_at, @intCast(path.len));
@memcpy(blob[fixed_len..][0..path.len], path);
const body = blob[header_len..][0..body_len];
const table = blob[header_len + body_len ..];
// The new entry, its blocks' CRCs computed here.
writeSlot(blob, fixed_len + path.len, 0, new.checkin, @intCast(new.lines.len), 0, new_len);
const e = body[0..new_len];
writeInt(u32, e, 0, @intCast(new.origins.len));
var at: usize = 8 + new.origins.len * 4;
for (new.origins, 0..) |o, i| {
writeInt(u32, e, 8 + i * 4, @intCast(at));
e[at] = @intCast(o.hash.len);
@memcpy(e[at + 1 ..][0..o.hash.len], o.hash);
at += 1 + o.hash.len;
e[at] = @intFromBool(o.user != null);
const user = o.user orelse "";
writeInt(u16, e, at + 1, @intCast(user.len));
@memcpy(e[at + 3 ..][0..user.len], user);
at += 3 + user.len;
}
writeInt(u32, e, 4, @intCast(at));
for (new.lines, 0..) |l, i| {
std.debug.assert(l < new.origins.len);
writeInt(u16, e, at + i * 2, l);
}
std.debug.assert(at + new.lines.len * 2 == new_len);
const new_blocks = padded(new_len) / blockcrc.block_len;
blockcrc.writeTable(body[0..padded(new_len)], table[0 .. new_blocks * 4]);
// The older entries, block for block with the CRCs they came with.
var dst = padded(new_len);
for (keep[0..n_keep], 1..) |k, slot| {
const p = prior.?;
const s = slotAt(p.bytes, p.slots_at, k);
const len = padded(s.len);
@memcpy(body[dst..][0..len], p.verifier.body[s.at..][0..len]);
const from = s.at / blockcrc.block_len * 4;
@memcpy(table[dst / blockcrc.block_len * 4 ..][0 .. len / blockcrc.block_len * 4], p.verifier.table[from..][0 .. len / blockcrc.block_len * 4]);
writeSlot(blob, fixed_len + path.len, slot, s.hash, s.line_count, dst, s.len);
dst += len;
}
std.debug.assert(dst == body_len);
writeInt(u32, blob, 20, crc32.Crc32.hash(table));
writeInt(u32, blob, header_len - 4, crc32.Crc32.hash(blob[0 .. header_len - 4]));
return blob;
}
fn writeSlot(blob: []u8, slots_at: usize, k: usize, hash: []const u8, line_count: u32, at: usize, len: usize) void {
const o = slots_at + k * slot_len;
std.debug.assert(hash.len <= 64);
blob[o] = @intCast(hash.len);
@memcpy(blob[o + 1 ..][0..hash.len], hash);
writeInt(u32, blob, o + 65, line_count);
writeInt(u32, blob, o + 69, @intCast(at));
writeInt(u32, blob, o + 73, @intCast(len));
}
/// A path's cache as found on disk: the mapping and the parse over it.
pub const Mapped = struct {
io: std.Io,
mm: std.Io.File.MemoryMap,
file: File,
pub fn deinit(self: *Mapped, gpa: std.mem.Allocator) void {
self.file.deinit(gpa);
self.mm.destroy(self.io);
self.* = undefined;
}
};
/// `path`'s cache under `dir`, mapped read-only, or null on any miss.
pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, path: []const u8, fp: u32) ?Mapped {
var name_buf: [dirname.len + 1 + 16]u8 = undefined;
var f = dir.openFile(io, memberPath(&name_buf, path), .{}) catch return null;
defer f.close(io); // a mapping outlives its descriptor
const st = f.stat(io) catch return null;
const size = std.math.cast(usize, st.size) orelse return null;
if (size == 0 or size > max_file_bytes) return null;
var mm = f.createMemoryMap(io, .{ .len = size, .protection = .{ .read = true }, .populate = false }) catch return null;
const file = (File.parse(gpa, mm.memory, path, fp) catch null) orelse {
mm.destroy(io);
return null;
};
return .{ .io = io, .mm = mm, .file = file };
}
/// Write `new` into `path`'s cache, carrying `prior`'s entries over; pass
/// null for `prior` after a read found the file damaged. Atomic (temp +
/// fsync + rename) and best-effort: a read-only store or filesystem, or any
/// other failure, leaves the cache as it was.
pub fn store(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, path: []const u8, fp: u32, new: Entry, prior: ?*const File) void {
const blob = (encode(gpa, path, fp, new, prior) catch return) orelse return;
defer gpa.free(blob);
if (!repocache.ensureCacheDir(io, dir)) return;
dir.createDir(io, dirname, .default_dir) catch |e| switch (e) {
error.PathAlreadyExists => {},
else => return,
};
var name_buf: [dirname.len + 1 + 16]u8 = undefined;
var af = dir.createFileAtomic(io, memberPath(&name_buf, path), .{ .replace = true }) catch return;
defer af.deinit(io);
af.file.writePositionalAll(io, blob, 0) catch return;
af.file.sync(io) catch return;
af.replace(io) catch return;
}
// --- tests -----------------------------------------------------------------
const testing = std.testing;
const repeat = @import("repeat.zig").repeat;
const hash_a = repeat("a", 64);
const hash_b = repeat("b", 64);
const hash_c = repeat("c", 40);
fn entryOf(checkin: []const u8, lines: []const u16) Entry {
return .{
.checkin = checkin,
.origins = &.{ .{ .hash = hash_a, .user = "alice" }, .{ .hash = hash_c, .user = null }, .{ .hash = hash_b, .user = "" } },
.lines = lines,
};
}
fn expectEntry(f: *File, slot: usize, want: Entry) !void {
try testing.expectEqual(@as(u32, @intCast(want.lines.len)), f.lineCount(slot));
var v = try f.read(testing.allocator, slot);
defer v.deinit(testing.allocator);
try testing.expectEqual(want.origins.len, v.origins.len);
for (want.origins, v.origins) |w, g| {
try testing.expectEqualStrings(w.hash, g.hash);
if (w.user) |u| try testing.expectEqualStrings(u, g.user.?) else try testing.expect(g.user == null);
}
for (want.lines, 0..) |l, i| try testing.expectEqual(l, v.lineAt(i));
}
test "blamecache: an entry round-trips, users null and empty kept apart" {
const gpa = testing.allocator;
const e = entryOf(hash_a, &.{ 0, 2, 1, 0 });
const blob = (try encode(gpa, "src/a.c", 7, e, null)).?;
defer gpa.free(blob);
var f = (try File.parse(gpa, blob, "src/a.c", 7)).?;
defer f.deinit(gpa);
try testing.expectEqual(@as(?usize, 0), f.find(hash_a));
try testing.expectEqual(@as(?usize, null), f.find(hash_b));
try expectEntry(&f, 0, e);
// An empty file has an entry and no lines.
const empty = (try encode(gpa, "e", 7, .{ .checkin = hash_c, .origins = &.{}, .lines = &.{} }, null)).?;
defer gpa.free(empty);
var fe = (try File.parse(gpa, empty, "e", 7)).?;
defer fe.deinit(gpa);
try testing.expectEqual(@as(u32, 0), fe.lineCount(fe.find(hash_c).?));
}
test "blamecache: entries are kept newest first, at most eight, one per check-in" {
const gpa = testing.allocator;
var names: [12][64]u8 = undefined;
var blob = (try encode(gpa, "p", 1, entryOf(hash_a, &.{0}), null)).?;
defer gpa.free(blob);
for (&names, 0..) |*n, i| {
_ = std.fmt.bufPrint(n, "{x:0>64}", .{i + 1}) catch unreachable;
var f = (try File.parse(gpa, blob, "p", 1)).?;
defer f.deinit(gpa);
const next = (try encode(gpa, "p", 1, entryOf(n, &.{ 1, @intCast(i % 3) }), &f)).?;
gpa.free(blob);
blob = next;
}
var f = (try File.parse(gpa, blob, "p", 1)).?;
defer f.deinit(gpa);
try testing.expectEqual(@as(u16, max_entries), f.n_entries);
for (0..max_entries) |k| {
const i = names.len - 1 - k;
try testing.expectEqual(@as(?usize, k), f.find(&names[i]));
try expectEntry(&f, k, entryOf(&names[i], &.{ 1, @intCast(i % 3) }));
}
try testing.expectEqual(@as(?usize, null), f.find(hash_a));
// Writing a check-in already held replaces it rather than doubling it.
const again = (try encode(gpa, "p", 1, entryOf(&names[5], &.{2}), &f)).?;
defer gpa.free(again);
var g = (try File.parse(gpa, again, "p", 1)).?;
defer g.deinit(gpa);
try testing.expectEqual(@as(?usize, 0), g.find(&names[5]));
try testing.expectEqual(@as(u16, max_entries), g.n_entries);
try expectEntry(&g, 0, entryOf(&names[5], &.{2}));
}
test "blamecache: another path, fingerprint, version, a truncation or a header flip is a miss" {
const gpa = testing.allocator;
const blob = (try encode(gpa, "p", 9, entryOf(hash_a, &.{ 0, 1 }), null)).?;
defer gpa.free(blob);
try testing.expect((try File.parse(gpa, blob, "q", 9)) == null);
try testing.expect((try File.parse(gpa, blob, "p", 8)) == null);
try testing.expect((try File.parse(gpa, blob[0 .. blob.len - 1], "p", 9)) == null);
const copy = try gpa.dupe(u8, blob);
defer gpa.free(copy);
// A format-version bump: a valid file under the old number is refused.
writeInt(u16, copy, 8, version + 1);
writeInt(u32, copy, fixed_len + 1 + slot_len, crc32.Crc32.hash(copy[0 .. fixed_len + 1 + slot_len]));
try testing.expect((try File.parse(gpa, copy, "p", 9)) == null);
@memcpy(copy, blob);
for (0..fixed_len + 1 + slot_len + 4) |i| {
copy[i] ^= 0x40;
try testing.expect((try File.parse(gpa, copy, "p", 9)) == null);
copy[i] ^= 0x40;
}
}
test "blamecache: a body that is not whole blocks is refused, even with every CRC right" {
const gpa = testing.allocator;
const blob = (try encode(gpa, "p", 1, entryOf(hash_a, &.{ 0, 1 }), null)).?;
defer gpa.free(blob);
const header_len = bodyStart(blob);
const short = blockcrc.block_len - 1;
const crafted = try gpa.alloc(u8, header_len + short + 4);
defer gpa.free(crafted);
@memcpy(crafted[0 .. header_len + short], blob[0 .. header_len + short]);
blockcrc.writeTable(crafted[header_len..][0..short], crafted[header_len + short ..]);
writeInt(u32, crafted, 16, short);
writeInt(u32, crafted, 20, crc32.Crc32.hash(crafted[header_len + short ..]));
writeInt(u32, crafted, header_len - 4, crc32.Crc32.hash(crafted[0 .. header_len - 4]));
try testing.expect((try File.parse(gpa, crafted, "p", 1)) == null);
}
test "blamecache: a flipped byte in any block fails the entry it lies in, and only that one" {
const gpa = testing.allocator;
var lines: [5000]u16 = undefined;
for (&lines, 0..) |*l, i| l.* = @intCast(i % 3);
const first = (try encode(gpa, "p", 1, entryOf(hash_a, &lines), null)).?;
defer gpa.free(first);
var f0 = (try File.parse(gpa, first, "p", 1)).?;
defer f0.deinit(gpa);
const blob = (try encode(gpa, "p", 1, entryOf(hash_b, lines[0..100]), &f0)).?;
defer gpa.free(blob);
const header_len = fixed_len + 1 + 2 * slot_len + 4;
const body_len: usize = readInt(u32, blob, 16);
try testing.expectEqual(@as(usize, 4), body_len / blockcrc.block_len);
const copy = try gpa.dupe(u8, blob);
defer gpa.free(copy);
for (0..body_len / blockcrc.block_len) |k| {
@memcpy(copy, blob);
copy[header_len + k * blockcrc.block_len + 3] ^= 1;
var f = (try File.parse(gpa, copy, "p", 1)).?;
defer f.deinit(gpa);
// Block 0 is the new entry's; 1..3 the carried one's.
const bad_slot: usize = if (k == 0) 0 else 1;
try testing.expectError(error.Corrupt, f.read(gpa, bad_slot));
}
// A flip in the carried entry survives a rewrite under its old CRC.
@memcpy(copy, blob);
copy[header_len + 2 * blockcrc.block_len] ^= 1;
var damaged = (try File.parse(gpa, copy, "p", 1)).?;
defer damaged.deinit(gpa);
const rewritten = (try encode(gpa, "p", 1, entryOf(hash_c, &.{0}), &damaged)).?;
defer gpa.free(rewritten);
var r = (try File.parse(gpa, rewritten, "p", 1)).?;
defer r.deinit(gpa);
try expectEntry(&r, r.find(hash_b).?, entryOf(hash_b, lines[0..100]));
try testing.expectError(error.Corrupt, r.read(gpa, r.find(hash_a).?));
}
test "blamecache: more origins than a u16 indexes, or a file past the bound, is not written" {
const gpa = testing.allocator;
const many = try gpa.alloc(Origin, max_origins + 1);
defer gpa.free(many);
for (many) |*o| o.* = .{ .hash = hash_a, .user = null };
try testing.expect((try encode(gpa, "p", 1, .{ .checkin = hash_a, .origins = many, .lines = &.{} }, null)) == null);
const lines = try gpa.alloc(u16, max_file_bytes / 2);
defer gpa.free(lines);
@memset(lines, 0);
try testing.expect((try encode(gpa, "p", 1, entryOf(hash_a, lines), null)) == null);
}
fn codecNoLeak(a: std.mem.Allocator) !void {
const first = (try encode(a, "p", 3, entryOf(hash_a, &.{ 0, 1, 2 }), null)).?;
defer a.free(first);
var f = (try File.parse(a, first, "p", 3)).?;
defer f.deinit(a);
const second = (try encode(a, "p", 3, entryOf(hash_b, &.{ 2, 2 }), &f)).?;
defer a.free(second);
var g = (try File.parse(a, second, "p", 3)).?;
defer g.deinit(a);
var v = try g.read(a, g.find(hash_a).?);
defer v.deinit(a);
try testing.expectEqual(@as(u16, 2), v.lineAt(2));
}
test "blamecache: the codec is leak-free under checkAllAllocationFailures" {
try testing.checkAllAllocationFailures(testing.allocator, codecNoLeak, .{});
}
test "blamecache: store then open round-trips under a directory, and a damaged file is a miss" {
const gpa = testing.allocator;
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try testing.expect(open(gpa, io, tmp.dir, "a.txt", 1) == null);
store(gpa, io, tmp.dir, "a.txt", 1, entryOf(hash_a, &.{ 0, 1 }), null);
var m = open(gpa, io, tmp.dir, "a.txt", 1).?;
try expectEntry(&m.file, 0, entryOf(hash_a, &.{ 0, 1 }));
store(gpa, io, tmp.dir, "a.txt", 1, entryOf(hash_b, &.{1}), &m.file);
m.deinit(gpa);
var m2 = open(gpa, io, tmp.dir, "a.txt", 1).?;
defer m2.deinit(gpa);
try testing.expectEqual(@as(u16, 2), m2.file.n_entries);
try testing.expect(open(gpa, io, tmp.dir, "b.txt", 1) == null);
try testing.expect(open(gpa, io, tmp.dir, "a.txt", 2) == null);
// Another path's file swapped in under this name is a miss.
store(gpa, io, tmp.dir, "b.txt", 1, entryOf(hash_a, &.{0}), null);
var a_buf: [dirname.len + 1 + 16]u8 = undefined;
var b_buf: [dirname.len + 1 + 16]u8 = undefined;
try tmp.dir.rename(memberPath(&b_buf, "b.txt"), tmp.dir, memberPath(&a_buf, "a.txt"), io);
try testing.expect(open(gpa, io, tmp.dir, "a.txt", 1) == null);
}
test "blamecache: the fingerprint moves with the shun table, the thinned list and the cone" {
const gpa = testing.allocator;
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const base = fingerprint(gpa, io, tmp.dir, 0).?;
try testing.expectEqual(base, fingerprint(gpa, io, tmp.dir, 0).?);
try testing.expect(base != fingerprint(gpa, io, tmp.dir, 1).?);
try tmp.dir.writeFile(io, .{ .sub_path = thinned.filename, .data = "" });
const thin = fingerprint(gpa, io, tmp.dir, 0).?;
try testing.expect(thin != base);
try tmp.dir.writeFile(io, .{ .sub_path = thinned.filename, .data = hash_a ++ "\n" });
try testing.expect(thin != fingerprint(gpa, io, tmp.dir, 0).?);
try tmp.dir.createDir(io, repocache.cache_dirname, .default_dir);
const before = fingerprint(gpa, io, tmp.dir, 0).?;
try tmp.dir.writeFile(io, .{ .sub_path = repocache.store_cone_filename, .data = "x" });
try testing.expect(before != fingerprint(gpa, io, tmp.dir, 0).?);
}