Artifact 58162493d5b8a5e9c546b4bdd42402368c802a52740a255c13ae0df518ea2ae2:
- File
src/cmd/verify.zig
— part of check-in
[bcb9f8eb6e]
at
2026-07-31 20:52:45
on branch logstore-open-cost
— verify: the explicit fsck verb
Slice 2 will extend header-only scanning to openReadOnly and delete verified_on_open, which removes eager corruption detection entirely. This is the on-demand replacement that has to exist first -- the same split git makes between normal operation and git fsck.
Two passes over the store. FrameVerifier walks every frame under .verify_payloads, which is the mode the open paths are shedding; it is built on StorageFile rather than an opened LogStore, so it still works on a store that will not open -- the case an fsck exists for. Then openForVerify opens .headers_only and reconstructs each artifact, which catches a frame whose checksum is honest about bytes that are not what their name claims.
Both passes report rather than abort. A payload CRC fault carries a header-proven frame_len, so the walk steps over it and inventories the rest; only a header-window fault, whose length cannot be trusted, stops the walk. A torn tail is not a fault -- open recovers it silently, and it means an interrupted write, not rot. An unreadable payload is a finding too, not the end of the inventory.
Exit codes stay 0/1/2 as every other verb documents them; 2 remains usage error and never corruption. Findings stream to stderr as they are found, the clean summary goes to stdout, and on any fault stdout is empty.
Scope stops at content artifacts. A manifest whose bytes hash correctly is byte-identical to what commit wrote and what sync validated on ingest, so re-parsing it would catch a zit bug rather than storage rot. The help text says so.
openForVerify is deliberately not openReadOnly: openReadOnly sets verified_on_open, which makes readFrameAt skip the per-read whole-frame CRC, and it scans .verify_payloads, so on exactly the damaged store an operator is fsck-ing it aborts before pass 2 runs. Slice 2 makes the two identical and openForVerify is deleted in that commit; its doc comment says so.
The chunk buffer in scanOneFrame's payload loop widens from 4 KiB to 64 KiB, matching cloneInto's existing precedent. Post-Slice-2 that loop runs only under verify, where an operator's wall-clock is what it costs.
zit verify is not an RFC-mandated verb. repo-state 7.3 blesses full rebuild as a fallback, but the verb's shape is a local design decision, not a protocol requirement.
Verified: fmt clean, test 2327/2327, gate 19/19, e2e 671/671. Four mutants, all compiling, each red for its own semantic reason: pointing the frame pass at .headers_only, stubbing artifact.verify to true, dropping the unreadable-store tally, and reverting the payload read to a hard error. Exit arms smoke-checked against the built binary. (user: dmestas, size: 17820) [annotate] [blame] [check-ins using] [more...]
//! `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("../cli.zig"); const logstore = @import("zit").logstore; const storage = @import("zit").storage; const artifact = @import("zit").artifact; // 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 { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); var file = root_dir.openFile(io, repo_filename, .{}) 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 = .{}; // --- 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) { // `openForVerify` 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); } // --- verdict ------------------------------------------------------------ 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", _ => "unknown frame kind", }; } /// Pass 2. Opens the store through `openForVerify` — NOT `openReadOnly`, for /// two reasons that both matter here: `openReadOnly` scans `.verify_payloads`, /// so on exactly the store being fsck-ed it would abort and this pass would /// never run; and it sets `verified_on_open`, which makes every frame read below /// skip its own checksum. fn verifyArtifacts( gpa: std.mem.Allocator, streams: cli.Streams, file: storage.StorageFile, tally: *Tally, ) 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.openForVerify(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; // `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 {}; } } }