filter: separate the compiled bodies in the checksum, a name moving between them was invisible
This commit is contained in:
+52
-35
@@ -220,9 +220,8 @@ pub const SourceStatus = struct {
|
||||
/// **not** the header, so it stays stable across a refetch of unchanged content
|
||||
/// while `fetched_at` moves.
|
||||
///
|
||||
/// The `.allow` body is hashed last so that a source with no exceptions keeps
|
||||
/// the digest it had when only two bodies existed: every checksum written before
|
||||
/// exceptions were honoured stays valid, and no upgrade forces a refetch.
|
||||
/// Each body is followed by a separator byte, so the digest identifies which
|
||||
/// body a name sits in rather than only which names were written.
|
||||
pub const Header = struct {
|
||||
url: []const u8,
|
||||
format: parsers.Format,
|
||||
@@ -591,11 +590,10 @@ pub const Manager = struct {
|
||||
};
|
||||
bodies.appendAssumeCapacity(wild_bytes);
|
||||
|
||||
// A missing `.allow` file is an empty allow body, not a failure. Two
|
||||
// sources are in that state and both are ordinary: one compiled before
|
||||
// exceptions were honoured, and one whose list carries no `@@` line.
|
||||
// Because the empty body contributes nothing to the checksum, the
|
||||
// stored digest of either still matches.
|
||||
// A missing `.allow` file is an empty allow body, not a failure. The
|
||||
// digest still covers three bodies, the third of them empty, so a
|
||||
// source whose list carries no `@@` line matches whether its empty
|
||||
// `.allow` file survived or not.
|
||||
const allow_bytes: []const u8 = blk: {
|
||||
const read = dir.readFileAlloc(io, allow_name, self.gpa, .limited(max_compiled_bytes)) catch |err| {
|
||||
if (err == error.OutOfMemory) return error.OutOfMemory;
|
||||
@@ -822,19 +820,19 @@ pub const Manager = struct {
|
||||
if (std.mem.eql(u8, stored, &compiled.result.checksum) and
|
||||
self.diskBodiesMatch(io, dir, row.id, stored))
|
||||
{
|
||||
// The three written counts come from the row: the checksum
|
||||
// covers the three bodies, so an unchanged checksum means an
|
||||
// unchanged number of entries in each. The two skip counts do
|
||||
// not: a skipped line lands in no body, so a list that changed
|
||||
// only its regex or browser-syntax lines arrives here with a
|
||||
// stale row and a fresh compile. Taking them from the row would
|
||||
// show the operator one number and restore another after a
|
||||
// restart.
|
||||
// Every count comes from the compile that just ran, not from
|
||||
// the row. The two skip counts have to: a skipped line lands in
|
||||
// no body, so a list that changed only its regex or
|
||||
// browser-syntax lines reaches here with a stale row. The three
|
||||
// written counts equal the row's anyway once the digest is
|
||||
// framed, so reading them from the compile costs nothing and
|
||||
// leaves no field whose freshness rests on an argument about
|
||||
// what the checksum covers.
|
||||
try sources_repo.updateSourceStats(self.database, row.id, .{
|
||||
.last_updated = now,
|
||||
.domain_count = row.domain_count,
|
||||
.wildcard_count = row.wildcard_count,
|
||||
.exception_count = row.exception_count,
|
||||
.domain_count = compiled.result.counts.domains,
|
||||
.wildcard_count = compiled.result.counts.wildcards,
|
||||
.exception_count = compiled.result.counts.exceptions,
|
||||
.skipped_regex_count = compiled.result.counts.skipped_regex,
|
||||
.skipped_unsupported_count = compiled.result.counts.skipped_unsupported,
|
||||
.checksum = stored,
|
||||
@@ -1078,10 +1076,9 @@ pub const Manager = struct {
|
||||
/// the rewrite path — the only path that can repair it.
|
||||
///
|
||||
/// A missing `.allow` file is the one exception, and it is the same one
|
||||
/// `loadSource` makes: a source compiled before exceptions were honoured has
|
||||
/// no such file, and its stored checksum was taken over an empty allow body.
|
||||
/// Answering false there would rewrite every list on the first refresh after
|
||||
/// an upgrade for no change in content.
|
||||
/// `loadSource` makes: it reads as an empty allow body, which is what a list
|
||||
/// with no `@@` line compiles to anyway. Answering false there would rewrite
|
||||
/// such a list on every refresh for no change in content.
|
||||
fn diskBodiesMatch(self: *Manager, io: std.Io, dir: std.Io.Dir, id: i64, expected: []const u8) bool {
|
||||
var list_buf: [name_buf_len]u8 = undefined;
|
||||
var wild_buf: [name_buf_len]u8 = undefined;
|
||||
@@ -1653,15 +1650,20 @@ fn rejectedWithoutEntries(counts: compiler.Counts) bool {
|
||||
return counts.invalid != 0 or counts.skipped_unsupported != 0 or counts.long_lines != 0;
|
||||
}
|
||||
|
||||
/// The digest the `.list`, `.wild` and `.allow` bodies share, in that order.
|
||||
/// The allow body comes last so that hashing an empty one leaves the digest of
|
||||
/// the two-body form untouched, which is what keeps every checksum stored before
|
||||
/// exceptions were honoured valid.
|
||||
/// The digest the `.list`, `.wild` and `.allow` bodies share, in that order,
|
||||
/// each followed by `compiler.body_separator`.
|
||||
///
|
||||
/// Must stay byte-for-byte what `compiler.compile` produces, separators
|
||||
/// included: this is the other half of the same digest, and the two are
|
||||
/// compared against each other on every refresh.
|
||||
fn bodyChecksum(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) [64]u8 {
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update(list_body);
|
||||
hasher.update(compiler.body_separator);
|
||||
hasher.update(wild_body);
|
||||
hasher.update(compiler.body_separator);
|
||||
hasher.update(allow_body);
|
||||
hasher.update(compiler.body_separator);
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
return std.fmt.bytesToHex(digest, .lower);
|
||||
@@ -2412,23 +2414,33 @@ test "compiledBodiesMatch verifies the bodies, not the presence of the files" {
|
||||
try testing.expect(!compiledBodiesMatch("", "", "", &expected));
|
||||
}
|
||||
|
||||
test "a source with no exceptions keeps the checksum it had before the allow body existed" {
|
||||
test "bodyChecksum separates the three bodies" {
|
||||
const list_body = "a.example.com\nb.example.com\n";
|
||||
const wild_body = "c.example.com\n";
|
||||
|
||||
// What an older nxdns stored: the digest of the two bodies alone. It is what
|
||||
// sits in `blocklist_sources.checksum` on every installation being upgraded,
|
||||
// and the files on disk are the two it was taken over.
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update(list_body);
|
||||
hasher.update(compiler.body_separator);
|
||||
hasher.update(wild_body);
|
||||
hasher.update(compiler.body_separator);
|
||||
hasher.update(compiler.body_separator);
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
const stored = std.fmt.bytesToHex(digest, .lower);
|
||||
const expected = std.fmt.bytesToHex(digest, .lower);
|
||||
|
||||
try testing.expectEqualStrings(&stored, &bodyChecksum(list_body, wild_body, ""));
|
||||
try testing.expectEqualStrings(&expected, &bodyChecksum(list_body, wild_body, ""));
|
||||
// No `.allow` file: what `loadSource` and `diskBodiesMatch` pass for one.
|
||||
try testing.expect(compiledBodiesMatch(list_body, wild_body, "", &stored));
|
||||
// It is an empty body, and an empty body still gets its separator.
|
||||
try testing.expect(compiledBodiesMatch(list_body, wild_body, "", &expected));
|
||||
|
||||
// The framing itself: the same bytes in a different body is a different
|
||||
// digest. Unframed these two are equal, and a stale `.list` survives an
|
||||
// upstream that switched the name to a wildcard.
|
||||
try testing.expect(!std.mem.eql(
|
||||
u8,
|
||||
&bodyChecksum("a.example\n", "", ""),
|
||||
&bodyChecksum("", "a.example\n", ""),
|
||||
));
|
||||
}
|
||||
|
||||
test "rejectedWithoutEntries fails a compile that produced nothing usable" {
|
||||
@@ -2552,7 +2564,12 @@ test "collectSample steps over a line that does not fit the reader buffer" {
|
||||
test "bodyChecksum covers the list body, then the wild body, then the allow body" {
|
||||
const all = bodyChecksum("a.example.com\n", "b.example.com\n", "c.example.com\n");
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update("a.example.com\nb.example.com\nc.example.com\n");
|
||||
hasher.update("a.example.com\n");
|
||||
hasher.update(compiler.body_separator);
|
||||
hasher.update("b.example.com\n");
|
||||
hasher.update(compiler.body_separator);
|
||||
hasher.update("c.example.com\n");
|
||||
hasher.update(compiler.body_separator);
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &all);
|
||||
|
||||
Reference in New Issue
Block a user