// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The syntax of a git configuration file, as git-config(1) describes it //! and as `git config --list` was seen to read it, on bytes with no I/O. //! //! What the documentation leaves out, found by experiment: //! //! - A UTF-8 byte order mark is skipped at the very start of a file, and //! nowhere else. //! - A carriage return before a line feed is part of the line end //! everywhere. Alone, it is blank space at the start of a line and in an //! unquoted value, and an ordinary byte elsewhere. Vertical tab and form //! feed are never blank space. //! - A section name may hold letters, digits, `-` and `.`, and may start //! with any of them; it is lowered, dots and all, so `[Core.Sub]` is the //! section `core.sub`. Between it and a quoted subsection go spaces or //! tabs, at least one, and the closing quote must be followed at once by //! `]`. A subsection may hold any byte but a line feed, NUL included; //! `\` keeps the byte after it, whatever it is. What follows `]` on the //! same line is read as though it began a line. //! - A variable may appear before any section, and is then named by its //! name alone. //! - An unquoted value loses blank space at its start and end, but keeps //! it inside; blank space is dropped while nothing has yet been kept, //! so `k = "" x` is `x`. `\` followed by a line end joins the next line, //! in quotes or out, and a `\` at the very end of the file is dropped. //! - Keys and values end at a NUL byte, as git's strings do: the rest of //! the value is read, and lost. //! - The line a malformed file is reported at is usually the line the //! mistake is on, but a section header that runs into the end of the //! file, or that ends a line after its closing quote, is reported at //! the line after it, as git reports it. const std = @import("std"); const Allocator = std.mem.Allocator; /// One variable read from a file. pub const Entry = struct { /// The variable's full name: its section and variable names lowered, /// its subsection as written, joined by dots. Valid until the next /// call to `Parser.next`. key: []const u8, /// Its value, or null for a variable written without `=`, which is /// true. Borrowed from the input when nothing in it needed undoing, /// and otherwise valid until the next call to `Parser.next`. value: ?[]const u8, /// The line it ends on, counting from 1. line: u32, }; /// What can go wrong reading a configuration file. pub const Error = error{ /// The file is malformed: git's "bad config line". `Parser.line` /// says which line git names. BadConfigLine, } || Allocator.Error; /// Reads the variables of a configuration file one at a time. pub const Parser = struct { input: []const u8, pos: usize, /// The line being read; after an error, the line git reports. line: u32 = 1, /// The current section, and subsection if any, as keys begin. prefix: std.ArrayList(u8) = .empty, key: std.ArrayList(u8) = .empty, value: std.ArrayList(u8) = .empty, pending: std.ArrayList(u8) = .empty, /// Whether the value just read ended with a line feed. value_ended_line: bool = false, const bom = "\xef\xbb\xbf"; /// A parser for the file `input`. pub fn init(input: []const u8) Parser { return .{ .input = input, .pos = if (std.mem.startsWith(u8, input, bom)) bom.len else 0 }; } /// Frees what the parser has allocated. pub fn deinit(p: *Parser, gpa: Allocator) void { p.prefix.deinit(gpa); p.key.deinit(gpa); p.value.deinit(gpa); p.pending.deinit(gpa); } const eof: u16 = 256; /// The next byte, with a carriage return and line feed read as one /// line feed, or `eof`. fn nextChar(p: *Parser) u16 { if (p.pos >= p.input.len) return eof; const c = p.input[p.pos]; p.pos += 1; if (c == '\r' and p.pos < p.input.len and p.input[p.pos] == '\n') { p.pos += 1; return '\n'; } return c; } fn fail(p: *Parser, line: u32) Error { p.line = line; return error.BadConfigLine; } /// The next variable, or null at the end of the file. pub fn next(p: *Parser, gpa: Allocator) Error!?Entry { while (true) { const c = p.nextChar(); switch (c) { eof => return null, '\n' => p.line += 1, ' ', '\t', '\r' => {}, '#', ';' => p.skipComment(), '[' => try p.sectionHeader(gpa), else => { if (c < 256 and std.ascii.isAlphabetic(@intCast(c))) return try p.variable(gpa, @intCast(c)); return p.fail(p.line); }, } } } /// Skips to the end of the line, leaving the line feed to be read. fn skipComment(p: *Parser) void { const end = std.mem.findScalarPos(u8, p.input, p.pos, '\n') orelse p.input.len; p.pos = end; } fn sectionHeader(p: *Parser, gpa: Allocator) Error!void { p.prefix.clearRetainingCapacity(); while (true) { const c = p.nextChar(); switch (c) { ']' => { if (p.prefix.items.len == 0) return p.fail(p.line); return; }, ' ', '\t' => { if (p.prefix.items.len == 0) return p.fail(p.line); return p.subsection(gpa); }, eof => return p.fail(p.line + 1), else => { if (c < 256) { const b: u8 = @intCast(c); if (std.ascii.isAlphanumeric(b) or b == '-' or b == '.') { try p.prefix.append(gpa, std.ascii.toLower(b)); continue; } } return p.fail(p.line); }, } } } /// The quoted subsection of a header, from the blank space after the /// section name to the closing `]`. fn subsection(p: *Parser, gpa: Allocator) Error!void { var c = p.nextChar(); while (c == ' ' or c == '\t') c = p.nextChar(); if (c != '"') return p.fail(p.line); try p.prefix.append(gpa, '.'); while (true) { c = p.nextChar(); switch (c) { '"' => break, '\n', eof => return p.fail(p.line), '\\' => { const e = p.nextChar(); if (e == '\n' or e == eof) return p.fail(p.line); try p.prefix.append(gpa, @intCast(e)); }, else => try p.prefix.append(gpa, @intCast(c)), } } c = p.nextChar(); switch (c) { ']' => {}, '\n', eof => return p.fail(p.line + 1), else => return p.fail(p.line), } } fn variable(p: *Parser, gpa: Allocator, first: u8) Error!Entry { p.key.clearRetainingCapacity(); try p.key.appendSlice(gpa, p.prefix.items); if (p.prefix.items.len != 0) try p.key.append(gpa, '.'); try p.key.append(gpa, std.ascii.toLower(first)); var c = p.nextChar(); while (c < 256 and (std.ascii.isAlphanumeric(@intCast(c)) or c == '-')) : (c = p.nextChar()) { try p.key.append(gpa, std.ascii.toLower(@intCast(c))); } while (c == ' ' or c == '\t') c = p.nextChar(); const key = truncateAtNul(p.key.items); switch (c) { eof => return .{ .key = key, .value = null, .line = p.line }, '\n' => { const line = p.line; p.line += 1; return .{ .key = key, .value = null, .line = line }; }, '=' => {}, else => return p.fail(p.line), } const value = try p.readValue(gpa); const line = p.line; if (p.value_ended_line) p.line += 1; return .{ .key = key, .value = truncateAtNul(value), .line = line }; } fn isBlank(c: u16) bool { return c == ' ' or c == '\t' or c == '\r'; } /// The value after `=`, up to and including the line feed that ends /// it. fn readValue(p: *Parser, gpa: Allocator) Error![]const u8 { if (p.simpleValue()) |v| return v; p.value.clearRetainingCapacity(); p.value_ended_line = false; // Blank space not yet known to be inside the value. p.pending.clearRetainingCapacity(); var quoted = false; while (true) { const c = p.nextChar(); switch (c) { eof => { if (quoted) return p.fail(p.line); return p.value.items; }, '\n' => { if (quoted) return p.fail(p.line); p.value_ended_line = true; return p.value.items; }, '\\' => { const e = p.nextChar(); const byte: u8 = switch (e) { // A backslash at the very end joins a line that // is not there. '\n', eof => { p.line += 1; continue; }, 't' => '\t', 'n' => '\n', 'b' => 0x08, '"' => '"', '\\' => '\\', else => return p.fail(p.line), }; try p.keepPending(gpa); try p.value.append(gpa, byte); }, '"' => quoted = !quoted, else => { if (!quoted and (c == ';' or c == '#')) { p.skipComment(); p.value_ended_line = p.nextChar() == '\n'; return p.value.items; } if (!quoted and isBlank(c)) { if (p.value.items.len == 0) continue; try p.pending.append(gpa, @intCast(c)); continue; } try p.keepPending(gpa); try p.value.append(gpa, @intCast(c)); }, } } } /// Keeps the blank space read since the last byte kept, now that /// something follows it. fn keepPending(p: *Parser, gpa: Allocator) Allocator.Error!void { try p.value.appendSlice(gpa, p.pending.items); p.pending.clearRetainingCapacity(); } /// The value, borrowed from the input, if it has no quotes, escapes or /// NULs to undo; having consumed it and the line feed after it. fn simpleValue(p: *Parser) ?[]const u8 { const rest = p.input[p.pos..]; const line_end = std.mem.findScalar(u8, rest, '\n') orelse rest.len; const line = rest[0..line_end]; var end = line.len; for (line, 0..) |c, i| switch (c) { '"', '\\', 0 => return null, ';', '#' => { end = i; break; }, else => {}, }; const value = std.mem.trim(u8, line[0..end], " \t\r"); p.value_ended_line = line_end != rest.len; p.pos += if (p.value_ended_line) line_end + 1 else line_end; return value; } }; fn truncateAtNul(s: []const u8) []const u8 { return s[0 .. std.mem.findScalar(u8, s, 0) orelse s.len]; } /// A variable's full name split into its parts. `section` and `name` are /// as the key holds them; `subsection` is null if there is none. pub const KeyParts = struct { section: []const u8, subsection: ?[]const u8, name: []const u8, }; /// Splits a full variable name at its first and last dots. pub fn splitKey(key: []const u8) KeyParts { const last = std.mem.findScalarLast(u8, key, '.') orelse return .{ .section = "", .subsection = null, .name = key }; const first = std.mem.findScalar(u8, key, '.').?; return .{ .section = key[0..first], .subsection = if (first == last) null else key[first + 1 .. last], .name = key[last + 1 ..], }; } /// Why a variable name given to look one up, or in `-c`, was refused, /// in git's words: "key does not contain a section", "key does not /// contain variable name", "invalid key", or "invalid key (newline)". pub const KeyError = error{ KeyHasNoSection, KeyHasNoName, InvalidKey, InvalidKeyNewline, }; /// Writes `key`, a variable name as a person would write it — /// `Section.Subsection.Name` — as configuration files spell it: the /// section and the name lowered and the subsection left alone. Refuses /// what git refuses: a key without a section or a name, a section of /// anything but letters, digits and `-`, a name that does not start with /// a letter or holds anything but letters, digits and `-`, and a /// subsection holding a line feed. pub fn canonicalKey(key: []const u8, out: []u8) KeyError![]u8 { std.debug.assert(out.len >= key.len); const last = std.mem.findScalarLast(u8, key, '.') orelse return error.KeyHasNoSection; if (last == 0) return error.KeyHasNoSection; if (last == key.len - 1) return error.KeyHasNoName; const first = std.mem.findScalar(u8, key, '.').?; for (key[0..first], 0..) |c, i| { if (!(std.ascii.isAlphanumeric(c) or c == '-')) return error.InvalidKey; out[i] = std.ascii.toLower(c); } for (key[first..last], first..) |c, i| { if (c == '\n') return error.InvalidKeyNewline; out[i] = c; } out[last] = '.'; const name = key[last + 1 ..]; if (!std.ascii.isAlphabetic(name[0])) return error.InvalidKey; for (name, last + 1..) |c, i| { if (!(std.ascii.isAlphanumeric(c) or c == '-')) return error.InvalidKey; out[i] = std.ascii.toLower(c); } return out[0..key.len]; } const testing = std.testing; const fuzz_input = @import("../testing/fuzz.zig"); /// What a file reads as, in `git config --list -z`'s format, or the error /// and line. fn listOf(input: []const u8) ![]u8 { const gpa = testing.allocator; var out: std.Io.Writer.Allocating = .init(gpa); errdefer out.deinit(); var p: Parser = .init(input); defer p.deinit(gpa); while (p.next(gpa) catch |err| switch (err) { error.BadConfigLine => { try out.writer.print("bad config line {d}", .{p.line}); return out.toOwnedSlice(); }, else => |e| return e, }) |e| { try out.writer.writeAll(e.key); if (e.value) |v| try out.writer.print("\n{s}", .{v}); try out.writer.writeByte(0); } return out.toOwnedSlice(); } fn expectList(want: []const u8, input: []const u8) !void { const got = try listOf(input); defer testing.allocator.free(got); errdefer std.debug.print("input: \"{f}\"\n", .{std.zig.fmtString(input)}); try testing.expectEqualStrings(want, got); } test "sections and subsections are named as git names them" { try expectList("core.k\nv\x00", "[Core]\nK=v"); try expectList("core.sub.k\nv\x00", "[core \t \"sub\"]\nk=v"); try expectList("core.sub.k\nv\x00", "[Core.Sub]\nk=v"); try expectList("core.a\"b\\ctd.k\nv\x00", "[core \"a\\\"b\\\\c\\td\"]\nk=v"); try expectList("core..k\nv\x00", "[core.]\nk=v"); try expectList("core.a.b.k\nv\x00", "[core.A \"b\"]\nk=v"); try expectList("1core.k\nv\x00", "[1core]\nk=v"); try expectList("core.junk\x00", "[core]junk"); try expectList("k\nv\x00", "k=v"); try expectList("core.a\nv\x00", "[core \"a\x00b\"]\nk=v"); try expectList("bad config line 1", "[ core ]\nk=v"); try expectList("bad config line 1", "[core\"sub\"]\nk=v"); try expectList("bad config line 1", "[core \"sub\" ]\nk=v"); try expectList("bad config line 1", "[co_re]\nk=v"); try expectList("bad config line 1", "[core\n]"); try expectList("bad config line 2", "[core"); try expectList("bad config line 2", "[core \"a\"\n"); try expectList("bad config line 2", "[core \"a\""); try expectList("bad config line 1", "[core \"a\n"); try expectList("bad config line 1", "[core "); try expectList("bad config line 4", "\n\n[core \"a\"\n"); } test "values are read as git reads them" { try expectList("s.k\na b\x00", "[s]\nk = a b \n"); try expectList("s.k\n a \x00", "[s]\nk = \" a \" \n"); try expectList("s.k\na b c\x00", "[s]\nk = a\" b \"c # x\n"); try expectList("s.k\na\x00", "[s]\nk = a;b\n"); try expectList("s.k\n\n\t\x08\"\\\x00", "[s]\nk = \\n\\t\\b\\\"\\\\\n"); try expectList("s.k\na b\x00", "[s]\nk = a\\\n b\n"); try expectList("s.k\na b\x00", "[s]\nk = a \\\n b \n"); try expectList("s.k\nb\x00", "[s]\nk=\\\n b\n"); try expectList("s.k\nb\x00", "[s]\nk=\"\" b\n"); try expectList("s.k\na\x00s.j\nb\x00", "[s]\r\nk = a\r\nj=b\r\n"); try expectList("s.k\na\rb\x00", "[s]\nk = a\rb\n"); try expectList("s.k\na\r\x0c\x00", "[s]\nk=a\r\x0c\n"); try expectList("s.k\na\x00s.j\n1\x00", "[s]\nk=a\x00b\nj=1\n"); try expectList("s.k\nv\x00", "\xef\xbb\xbf[s]\nk=v\n"); try expectList("s.k\x00", "[s]\nk \n"); try expectList("s.k\x00s.j\n\x00", "[s]\nk\r\nj=\n"); try expectList("s.k\na\x00", "[s]\nk=a\\"); try expectList("s.k\nv\x00", "[s]\nk=v\\\n# not a comment\n"); try expectList("bad config line 1", "\xef\xbb[s]\nk=v\n"); try expectList("bad config line 2", "\n\xef\xbb\xbf[s]\n"); try expectList("bad config line 2", "[s]\nk = \\a\n"); try expectList("bad config line 2", "[s]\nk ;c\n"); try expectList("bad config line 2", "[s]\nk_=v\n"); try expectList("bad config line 2", "[s]\nk=\"a\n"); try expectList("bad config line 2", "[s]\nk\r \n"); try expectList("bad config line 2", "[s]\nk=a\\\r"); try expectList("bad config line 3", "[s]\nk=a\\\n\\q\n"); try expectList("bad config line 2", "[s]\n\x0bk=v\n"); } test "a key is spelled as files spell it, or refused as git refuses it" { var buffer: [64]u8 = undefined; try testing.expectEqualStrings("s.Sub.k", try canonicalKey("S.Sub.K", &buffer)); try testing.expectEqualStrings("s..k", try canonicalKey("s..k", &buffer)); try testing.expectEqualStrings("s.a.b.c", try canonicalKey("s.a.b.c", &buffer)); try testing.expectError(error.KeyHasNoSection, canonicalKey("k", &buffer)); try testing.expectError(error.KeyHasNoSection, canonicalKey(".k", &buffer)); try testing.expectError(error.KeyHasNoName, canonicalKey("s.", &buffer)); try testing.expectError(error.InvalidKey, canonicalKey("s.1k", &buffer)); try testing.expectError(error.InvalidKey, canonicalKey("s_x.k", &buffer)); try testing.expectError(error.InvalidKeyNewline, canonicalKey("s.a\nb.k", &buffer)); const parts = splitKey("a.b.c.d"); try testing.expectEqualStrings("a", parts.section); try testing.expectEqualStrings("b.c", parts.subsection.?); try testing.expectEqualStrings("d", parts.name); } fn fuzzOne(_: void, smith: *testing.Smith) anyerror!void { const gpa = testing.allocator; var buffer: [4096]u8 = undefined; const input = fuzz_input.bytes(smith, &buffer); var p: Parser = .init(input); defer p.deinit(gpa); var last_line: u32 = 1; while (p.next(gpa) catch |err| switch (err) { error.BadConfigLine => { // A line no further than one past the end. try testing.expect(p.line >= last_line); return; }, else => |e| return e, }) |e| { try testing.expect(e.line >= last_line); last_line = e.line; try testing.expect(std.mem.findScalar(u8, e.key, 0) == null); if (e.value) |v| try testing.expect(std.mem.findScalar(u8, v, 0) == null); } } test "fuzz: configuration files" { try fuzz_input.fuzz({}, fuzzOne, .{ .corpus = &.{ @embedFile("../fuzz/config"), @embedFile("../fuzz/config-crlf"), @embedFile("../fuzz/config-bad"), } }); }