// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The contents of a loose ref: an object ID, or `ref: ` and the name of //! another ref. These are git's observed rules, from files written by //! hand and read back with `git rev-parse`, `git symbolic-ref` and //! `git for-each-ref`: //! //! - The contents end at their first NUL, if they have one. //! - `ref:` begins a symbolic ref. Blank space after it is skipped and //! blank space at the end dropped; what is left is the target, line //! feeds within it and all. A target that is not a well-formed ref name //! (one-level names allowed) makes the ref dangling, not unreadable. //! - Otherwise the contents must begin with exactly as many hexadecimal //! digits as an object ID has, in either case, followed by the end or //! by blank space; anything after that is ignored, which is how //! `FETCH_HEAD`'s extra columns and a CRLF line ending are passed over. //! Anything else — too few digits, too many, a leading space, other //! bytes after the digits — is a broken ref ("ignoring broken ref"). //! //! Blank space is git's: space, tab, line feed and carriage return; not //! vertical tab or form feed. const std = @import("std"); const object = @import("../object.zig"); const name_rules = @import("name.zig"); /// What a loose ref holds. pub const Value = union(enum) { /// An object ID. direct: object.Id, /// The name of another ref; a slice of the contents. symbolic: []const u8, }; /// Why loose ref contents could not be read: they are neither an object /// ID nor `ref: ` and a name. pub const Error = error{MalformedRef}; /// Reads the contents of a loose ref file. pub fn parse(format: object.Format, contents: []const u8) Error!Value { const text = contents[0 .. std.mem.findScalar(u8, contents, 0) orelse contents.len]; if (std.mem.cutPrefix(u8, text, "ref:")) |rest| { return .{ .symbolic = std.mem.trim(u8, rest, whitespace) }; } const n = format.hexLength(); if (text.len < n) return error.MalformedRef; if (text.len > n and !isSpace(text[n])) return error.MalformedRef; return .{ .direct = object.Id.parseHex(format, text[0..n]) catch return error.MalformedRef }; } /// Whether a symbolic ref's target can be followed at all: a target that /// is not a well-formed name makes git call the ref dangling. pub fn isValidTarget(target: []const u8) bool { return name_rules.isValid(target, .{ .allow_onelevel = true }); } /// git's blank space. pub const whitespace = " \t\n\r"; fn isSpace(c: u8) bool { return std.mem.findScalar(u8, whitespace, c) != null; } // -- tests ----------------------------------------------------------------------- const testing = std.testing; const fuzz_input = @import("../testing/fuzz.zig"); test "loose refs are read as git reads them" { const hex = "f262b5fdc70bf36bebcf70cc60cf788a294c8126"; const id = try object.Id.parseHex(.sha1, hex); for ([_][]const u8{ hex, hex ++ "\n", hex ++ "\r\n", hex ++ " garbage\n", hex ++ "\tx", hex ++ "\x00x", "F262B5FDC70BF36BEBCF70CC60CF788A294C8126\n" }) |c| { try testing.expect((try parse(.sha1, c)).direct.eql(id)); } for ([_][]const u8{ "", hex[0..39], hex ++ "0", hex ++ "x", " " ++ hex, hex ++ "\x0bjunk", "REF: refs/heads/main" }) |c| { try testing.expectError(error.MalformedRef, parse(.sha1, c)); } try testing.expectEqualStrings("refs/heads/main", (try parse(.sha1, "ref: refs/heads/main\n")).symbolic); try testing.expectEqualStrings("refs/heads/main", (try parse(.sha1, "ref:refs/heads/main")).symbolic); try testing.expectEqualStrings("refs/heads/main", (try parse(.sha1, "ref:\t refs/heads/main \r\n")).symbolic); try testing.expectEqualStrings("refs/heads/main\nextra", (try parse(.sha1, "ref: refs/heads/main\nextra\n")).symbolic); try testing.expect(!isValidTarget((try parse(.sha1, "ref: refs/heads/main\nextra\n")).symbolic)); try testing.expect(!isValidTarget((try parse(.sha1, "ref: refs/heads/main\x0b")).symbolic)); try testing.expect(isValidTarget("HEAD")); try testing.expect(!isValidTarget("")); } test "fuzz: loose refs" { try fuzz_input.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [256]u8 = undefined; const input = @import("../testing/fuzz.zig").bytes(smith, &buffer); for ([_]object.Format{ .sha1, .sha256 }) |f| { const v = parse(f, input) catch continue; switch (v) { .direct => |id| try testing.expectEqual(f, id.object_format), .symbolic => |t| _ = isValidTarget(t), } } } }.one, .{ .corpus = &.{ @embedFile("../fuzz/head-symref"), @embedFile("../fuzz/loose-ref"), @embedFile("../fuzz/loose-ref-sha256"), } }); }