Artifact 84325ab7abe99dadcbf31d7470b951e8b9cd2e6b5a4dccad7d394cf14bd3f063:
- File
src/cmd/verify.zig
— part of check-in
[709c457d95]
at
2026-08-22 17:29:58
on branch trunk
— users: carry fossil's user table, and read it
A .user frame kind holds login/pw/cap/info/mtime rows, repo-local like shun and folded newest-mtime-wins; pack carries the folded table and the scan index (v2) carries it too. 'zit config import|merge FILE' applies a fossil configuration export -- whose cards are byte-identical to the sync protocol's -- so a repository's users cross with no SQLite reader and no server. 'zit users' prints them; the password hash needs --raw. Caps are opaque and verbatim (#124). (user: dmestas, size: 21504) [annotate] [blame] [check-ins using]
//! `zit verify` — this repository's own fsck. //! //! NOT AN RFC-MANDATED VERB. repo-state §7.3 blesses a full rebuild from the //! artifacts as a recovery fallback, but it specifies nothing about a //! verification command; the shape of this one is zit's own design decision and //! no spec citation is claimed for it. //! //! It exists because the open-time scan is no longer where damage is noticed. //! The scan used to stream every payload byte through the crc on every open, //! which made corruption detection a side effect of paying Θ(repository bytes) //! to open. That cost is being deleted; this verb is the on-demand replacement, //! the same split git draws between ordinary operation and `git fsck`. //! //! TWO PASSES, DELIBERATELY LAYERED, because they fail independently: //! //! 1. The whole-log frame pass (`logstore.FrameVerifier`) reads every frame's //! bytes and checks the whole-frame checksum. It is built directly on the //! store FILE, so it works on a store no `open` would accept — which is the //! entire reason an fsck exists. It sees config records and shun rows, //! which the second pass structurally cannot. //! 2. The per-artifact pass reconstructs every stored artifact through its //! delta chain and re-hashes it against its own name. It catches a frame //! whose checksum is perfectly valid over the wrong bytes — a fault the //! first pass is blind to by construction. //! //! Scope stops at content artifacts: manifests are not re-parsed. A manifest //! whose bytes hash to its name is byte-identical to what `zit commit` wrote and //! what sync validated on ingest, so re-parsing it would catch a zit bug rather //! than storage rot. `verify_help` says so. const std = @import("std"); const cli = @import("zit").cli; const logstore = @import("zit").logstore; const storage = @import("zit").storage; const artifact = @import("zit").artifact; const repocache = @import("zit").repocache; const cone_mod = @import("zit").cone; // Shared CLI helpers that live in main.zig, aliased so this file reads like its // siblings in src/cmd/. const main = @import("../main.zig"); const repo_filename = main.repo_filename; const reasonOf = main.reasonOf; const stateOom = main.stateOom; const not_a_repo_msg = main.not_a_repo_msg; const repoReadFail = main.repoReadFail; const digestToWireName = main.digestToWireName; const verify_help = main.verify_help; // The two named-length couplings this file's output depends on, tied at // compile time rather than by coincidence: a frame's hash is at most // `max_hash_len` bytes and `digestToWireName` asserts its input fits // `digest_len`, so `name_buf` below is only the right size while the two agree. comptime { std.debug.assert(main.digest_len == logstore.max_hash_len); } /// Running tallies across both passes, so the verdict can state the byte /// accounting as a separate fact from the fault count — and can state WHICH /// pass found what. The split is not cosmetic: one physical bitflip in a /// content frame is found by both passes (a bad checksum, then an artifact that /// will not reconstruct), and a single aggregate "2 faults" would read as two /// damaged frames. const Tally = struct { frame_faults: u64 = 0, artifact_faults: u64 = 0, /// Artifacts the per-artifact pass reconstructed, or null when that pass /// did not run. Null rather than zero on purpose: "0 artifacts" would read /// as "this store holds none", which is a different claim from "nobody /// looked". artifacts: ?u64 = null, /// The per-artifact pass could not open the store at all. Its own kind of /// fault, and one that has to raise the exit code: a repository every other /// verb would refuse to open is not a clean repository, and `verify_help`'s /// exit table promises 1 when "the store could not be read". unreadable: bool = false, fn faults(self: Tally) u64 { return self.frame_faults + self.artifact_faults + @intFromBool(self.unreadable); } }; /// Checks this repository's store for damage. Read-only in the strongest sense: /// it takes no lock, opens nothing for write, and never truncates — so it is /// safe to run against a store a `zit serve` has open. /// /// Findings stream to stderr in walk order as they are found; the clean summary /// is the only thing that ever reaches stdout, so a faulty run leaves stdout /// empty and nothing downstream mistakes damage for a report. pub fn runVerify( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, amb: cli.Ambient, ) cli.Exit { const help = switch (cli.parseNoPositionalArgs(verb_args)) { .ok => |a| a.help, .unknown_option => |tok| return cli.usageError(streams, "verify", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "verify", .extra_argument, tok), }; if (help) { streams.out.writeAll(verify_help) catch {}; return .ok; } // `-R` is a global option parsed before dispatch, so it never reaches the // verb's own parser; `resolveRoot` is where the two routes converge. const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "verify")) orelse { main.reportNoRoot(streams, io, "verify", amb.repo_override); return .failure; }; var root_dir = root.dir; defer root_dir.close(io); var file = cli.openStoreRead(io, root_dir) catch return repoReadFail(streams, "verify"); defer file.close(io); const st = file.stat(io) catch return repoReadFail(streams, "verify"); var fs = storage.FileStorage.init(io, file, st.size); var tally: Tally = .{}; // Sparse-cone RFC section 5.2: when a store cone exists, the per-artifact // pass's plaintext stream also classifies every F-card reference, so the // verdict reports elision distinctly from damage -- and NEVER lets // thousands of deliberate absences read as findings. var sparse_tracker: ?SparseTracker = blk: { if (repocache.readStoreCone(gpa, io, root_dir)) |c| { break :blk SparseTracker{ .cone = c }; } break :blk null; }; defer if (sparse_tracker) |*t| t.deinit(gpa); // --- pass 1: every frame's whole-frame checksum ------------------------ var v = logstore.FrameVerifier.init(fs.storageFile()) catch |e| { // The FILE header itself. Nothing past it can be read as frames at all, // so there is no inventory to take and no second pass to attempt. streams.err.print("zit verify: the store's file header is unreadable: {s}\n", .{reasonOf(e)}) catch {}; streams.err.writeAll(advisory) catch {}; return .failure; }; var fatal = false; // STATED LIMIT ON OUTPUT: one stderr line per damaged frame, bounded by the // store's frame count and streamed as it is found. Deliberately uncapped — // the inventory IS the product here, and an operator who wants less can // pipe it; a cap would make the verb unable to answer the question it // exists to answer. while (v.next()) |fault| { tally.frame_faults += 1; renderFrameFault(streams, fault); if (fault.fatal()) { fatal = true; break; } } const totals = v.totals(); if (!totals.complete and !fatal) { // DEFENCE AGAINST THE WORST OUTPUT THIS VERB CAN PRODUCE. Today the // only way the walk ends short is a fatal fault, which is already // reported and already raises the exit code — so this branch is // unreachable. It exists because the coupling is invisible from here: // if a future change ever lets the walk stop early without saying why, // a short walk must be a fault rather than a silent "0 faults" over an // unexamined store. tally.frame_faults += 1; streams.err.print( "zit verify: the frame walk ended at offset {d} without reporting why\n", .{totals.bytes_checked}, ) catch {}; } if (totals.torn_tail_at) |at| { // A NOTE, NOT A FAULT, and it must not move the exit code: `open` // recovers a torn tail silently because it means an interrupted write. // Sending an operator to re-clone over an ordinary crash would be a // worse failure than the one being reported. streams.err.print( "zit verify: incomplete final frame at offset {d} from an interrupted write; the next write truncates it, this is not corruption\n", .{at}, ) catch {}; } // --- pass 2: every artifact reconstructed and re-hashed ----------------- if (fatal) { streams.err.writeAll("zit verify: per-artifact verification skipped: the frame walk could not continue\n") catch {}; } else if (totals.torn_tail_at != null) { // `openReadOnly` never truncates, so a torn tail is `error.Corrupt` // to it. Say which of the two passes did not run rather than reporting // a clean second pass that never happened. streams.err.writeAll("zit verify: per-artifact verification skipped: the store has an incomplete final frame\n") catch {}; } else { verifyArtifacts(gpa, streams, fs.storageFile(), &tally, if (sparse_tracker) |*t| t else null); } // --- verdict ------------------------------------------------------------ // Sparse-cone RFC section 5.2: elision is stated in the SUMMARY, never as // findings -- a complete-within-its-cone store is healthy, and an operator // must never learn to skim past thousands of per-artifact "missing" lines. if (sparse_tracker) |*t| { streams.out.print( "sparse store: cone of {d} member{s}; {d} artifact{s} deliberately elided\n", .{ t.cone.members.len, if (t.cone.members.len == 1) "" else "s", t.elidedCount(), if (t.elidedCount() == 1) "" else "s" }, ) catch {}; } if (tally.faults() == 0) { if (tally.artifacts) |n| { streams.out.print( "verified: {d} {s}, {d} {s}, {d} bytes, 0 faults\n", .{ totals.frames, plural(totals.frames, "frame", "frames"), n, plural(n, "artifact", "artifacts"), totals.bytes_checked }, ) catch {}; } else { // The per-artifact pass did not run; stderr already said why. The // summary must not imply it did. streams.out.print( "verified: {d} {s}, {d} bytes, 0 faults; per-artifact verification did not run\n", .{ totals.frames, plural(totals.frames, "frame", "frames"), totals.bytes_checked }, ) catch {}; } return .ok; } // Each pass's count is stated on its own, because one physical bitflip in a // content frame produces a finding from BOTH — and "2 faults" would read as // two damaged frames. "N of M bytes checked" is mandatory and is a SEPARATE // FACT again: a verb that reports one fault after stopping at 8% of the // store has lied about the other 92%. if (tally.artifacts != null) { streams.err.print( "zit verify: {d} frame {s}, {d} artifact {s}, {d} of {d} bytes checked\n", .{ tally.frame_faults, plural(tally.frame_faults, "fault", "faults"), tally.artifact_faults, plural(tally.artifact_faults, "fault", "faults"), totals.bytes_checked, totals.total, }, ) catch {}; } else { streams.err.print( "zit verify: {d} frame {s}, {d} of {d} bytes checked; per-artifact verification did not run\n", .{ tally.frame_faults, plural(tally.frame_faults, "fault", "faults"), totals.bytes_checked, totals.total }, ) catch {}; } streams.err.writeAll(advisory) catch {}; return .failure; } /// One unconditional closing line, never branched on shun state: an operator /// reading a damage report needs the repair route stated the same way every /// time, and a conditional hint is one more thing to be wrong about. const advisory = "zit verify: cloning from a healthy peer is the reliable repair; 'zit pack' drops artifacts already shunned\n"; fn plural(n: u64, one: []const u8, many: []const u8) []const u8 { return if (n == 1) one else many; } /// Renders one frame-pass finding. THE FULL LOWERCASE HEX NAME, never /// abbreviated: the line's job is to be pasted into `zit artifact` or /// `zit shun`, and a truncated name cannot be. /// /// The name is available on a payload fault at all because `readFrameHeader` /// checksum-verifies the prefix and header windows SEPARATELY from the payload, /// and the hash lives in the header window — so a payload bitflip leaves the /// name intact. That is what makes damage nameable from the frame pass alone. fn renderFrameFault(streams: cli.Streams, fault: logstore.FrameFault) void { const kind = fault.kind orelse { const why = switch (fault.cause) { error.Corrupt => "header checksum failed", error.Storage => "input/output error", }; streams.err.print( "zit verify: frame at offset {d}: {s}; cannot verify past this point\n", .{ fault.offset, why }, ) catch {}; return; }; var name_buf: [2 * logstore.max_hash_len]u8 = undefined; const name = digestToWireName(fault.name(), &name_buf); // A post-header fault is one of two things, and they are not the same // advice: damaged bytes on a working disk, or a disk that would not hand // the bytes over at all. The walk continues in both cases. const what = switch (fault.cause) { error.Corrupt => "payload checksum mismatch", error.Storage => "payload bytes could not be read", }; switch (kind) { .config => streams.err.print( "zit verify: frame at offset {d} (config record): {s}\n", .{ fault.offset, what }, ) catch {}, .shun => streams.err.print( "zit verify: frame at offset {d} (shun row for {s}): {s}\n", .{ fault.offset, name, what }, ) catch {}, else => streams.err.print( "zit verify: frame at offset {d} ({s}, {s}): {s}\n", .{ fault.offset, name, kindLabel(kind), what }, ) catch {}, } } /// A frame's payload encoding in operator words rather than field names. fn kindLabel(kind: logstore.FrameKind) []const u8 { return switch (kind) { .raw => "full content", .zlib => "compressed content", .delta => "delta", .config => "config record", .shun => "shun row", .user => "user row", _ => "unknown frame kind", }; } /// Pass 2. Opens the store through `openReadOnly`, which now scans /// `.headers_only` and grants no read-time trust — so it neither aborts on the /// damaged store being fsck-ed nor lets a frame read below skip its own /// checksum. Both properties used to require a separate `openForVerify`; that /// function became byte-identical to this one and was deleted. /// Streams the per-artifact pass's plaintext through an F-card classifier: /// distinct blob names referenced in-cone vs out-of-cone, plus the held set. /// `elidedCount` = out-only references neither held nor in-cone-referenced -- /// exactly RFC section 5.2's elided phantoms, computed without materializing /// a second store. Names are duped in; memory is bounded by distinct names. const SparseTracker = struct { cone: cone_mod.Cone, held: std.StringHashMapUnmanaged(void) = .empty, in_cone: std.StringHashMapUnmanaged(void) = .empty, out_cone: std.StringHashMapUnmanaged(void) = .empty, fn noteHeld(self: *SparseTracker, gpa: std.mem.Allocator, name: []const u8) void { putDupe(gpa, &self.held, name); } fn noteContent(self: *SparseTracker, gpa: std.mem.Allocator, plain: []const u8) void { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const im = artifact.parseForImport(arena_state.allocator(), plain) catch return; for (im.manifest.f) |fc| { const h = fc.hash orelse continue; if (self.cone.covers(fc.filename)) { putDupe(gpa, &self.in_cone, h); } else { putDupe(gpa, &self.out_cone, h); } } } fn putDupe(gpa: std.mem.Allocator, map: *std.StringHashMapUnmanaged(void), name: []const u8) void { if (map.contains(name)) return; const owned = gpa.dupe(u8, name) catch return; // best-effort: a lost note undercounts, never faults map.put(gpa, owned, {}) catch gpa.free(owned); } fn elidedCount(self: *const SparseTracker) usize { var n: usize = 0; var it = self.out_cone.keyIterator(); while (it.next()) |key| { if (self.held.contains(key.*)) continue; if (self.in_cone.contains(key.*)) continue; n += 1; } return n; } fn deinit(self: *SparseTracker, gpa: std.mem.Allocator) void { inline for (.{ &self.held, &self.in_cone, &self.out_cone }) |map| { var it = map.keyIterator(); while (it.next()) |key| gpa.free(key.*); map.deinit(gpa); } self.cone.deinit(gpa); self.* = undefined; } }; fn verifyArtifacts( gpa: std.mem.Allocator, streams: cli.Streams, file: storage.StorageFile, tally: *Tally, sparse: ?*SparseTracker, ) void { // A FAULT, NOT A SKIP. The frame pass walks raw bytes and can therefore // come up clean on a store `scan` still rejects — a checksum-valid but // malformed shun row is exactly that shape. Reporting "0 faults" and // exiting 0 on a repository every other verb refuses to open is the one // answer this verb must never give, and `verify_help`'s exit table already // promises 1 when "the store could not be read". var store = logstore.LogStore.openReadOnly(gpa, file) catch |e| { tally.unreadable = true; streams.err.print("zit verify: the store could not be opened for per-artifact verification: {s}\n", .{reasonOf(e)}) catch {}; return; }; defer store.deinit(gpa); const rids = store.ridsInPersistedOrder(gpa) catch { tally.unreadable = true; streams.err.writeAll("zit verify: the store could not be inventoried for per-artifact verification: out of memory\n") catch {}; return; }; defer gpa.free(rids); // From here the pass HAS run, so the count is a real zero rather than // "nobody looked" even if every rid turns out to be a phantom. tally.artifacts = 0; var cache = logstore.PlaintextCache.init(); defer cache.deinit(gpa); for (rids) |rid| { var name_buf: [2 * logstore.max_hash_len]u8 = undefined; const name = digestToWireName(store.hashOf(rid), &name_buf); // A shunned artifact whose bytes are still on disk IS verified and IS // reported. `zit pack` filters before verifying so corruption cannot // block its own removal; verify has no removal to block, and must not // claim clean about bytes it declined to look at. // `isShunned` asserts a COMPLETE digest width — the shun table is keyed // by complete artifact name and cannot answer about a prefix. A store // holding a frame of some other width is not something to trap on // inside an fsck, so the width is checked rather than assumed. const shunned = logstore.isCompleteDigest(store.hashOf(rid).len) and store.isShunned(store.hashOf(rid)); // `contentUnmasked`, not `content`: the masked reader answers null for a // shunned artifact, so a masked loop would report "0 faults" about bytes // it never read. const content = store.contentUnmasked(gpa, rid, &cache) catch |e| { // A read error is a FINDING, not an abort — the next artifact may // be fine, and an fsck that stopped here would under-report. tally.artifact_faults += 1; streams.err.print( "zit verify: artifact {s}{s}: cannot reconstruct: {s}\n", .{ name, if (shunned) " (shunned, not yet reclaimed)" else "", reasonOf(e) }, ) catch {}; continue; } orelse continue; // unreachable: `ridsInPersistedOrder` already drops phantoms defer gpa.free(content); tally.artifacts = tally.artifacts.? + 1; if (sparse) |t| { t.noteHeld(gpa, name); t.noteContent(gpa, content); } // `artifact.verify` is total — a name of any length it does not // recognise is `false`, never a trap — so unlike `isShunned` above it // needs no width guard. It does mean a store carrying some hash width // other than 20 or 32 bytes would be reported as a name mismatch; // nothing zit writes produces one, and a store that holds one is // damaged by any reading. if (!artifact.verify(name, content)) { tally.artifact_faults += 1; streams.err.print( "zit verify: artifact {s}{s}: content does not hash to its name\n", .{ name, if (shunned) " (shunned, not yet reclaimed)" else "" }, ) catch {}; } } }