Something went wrong. Try again.
Reading and writing git repositories in Zig, without running git.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Objects as git names and frames them: the object format a repository//! hashes with, the object IDs it names objects by, the four kinds of//! object, and the header (`<kind> <size>\0`) that comes before an object's//! contents wherever it is stored whole, and that its object ID is hashed//! over.//!//! None of this does any I/O. The object format is chosen per repository//! at run time (`extensions.objectFormat`, gitrepository-layout(5)), so an//! object ID carries its format with it rather than being a type per//! format: one process may read a SHA-1 repository and a SHA-256 one.
const std = @import("std");const Io = std.Io;
const Diagnostics = @import("Diagnostics.zig");
/// The hash algorithm a repository names its objects with.pub const Format = enum { sha1, sha256,
/// The format `extensions.objectFormat` names: `sha1` or `sha256`. pub fn fromName(text: []const u8) ?Format { return std.meta.stringToEnum(Format, text); }
/// The name `extensions.objectFormat` gives the format. pub fn name(f: Format) []const u8 { return @tagName(f); }
/// How many bytes an object ID has in this format. pub fn byteLength(f: Format) usize { return switch (f) { .sha1 => 20, .sha256 => 32, }; }
/// How many hexadecimal digits an object ID has in this format. pub fn hexLength(f: Format) usize { return f.byteLength() * 2; }};
/// The most bytes an object ID has in any format.pub const max_byte_length = 32;/// The most hexadecimal digits an object ID has in any format.pub const max_hex_length = max_byte_length * 2;
/// An object ID: the hash of an object's header and contents, in the/// object format it was computed in.pub const Id = struct { object_format: Format, /// The hash, in its first `object_format.byteLength()` bytes; the rest are /// zero, so that two IDs compare equal byte for byte. bytes: [max_byte_length]u8,
/// The object ID `raw` holds, which must be exactly as long as the /// format's IDs: the binary form trees and packs store. pub fn fromBytes(f: Format, raw: []const u8) Id { std.debug.assert(raw.len == f.byteLength()); var id: Id = .{ .object_format = f, .bytes = @splat(0) }; @memcpy(id.bytes[0..raw.len], raw); return id; }
/// The object ID written in `hex`, which must be exactly as long as the /// format's IDs, in digits of either case, as git accepts them in a /// commit's `tree` line or on its command line. pub fn parseHex(f: Format, hex: []const u8) error{InvalidObjectId}!Id { if (hex.len != f.hexLength()) return error.InvalidObjectId; var id: Id = .{ .object_format = f, .bytes = @splat(0) }; for (id.bytes[0..f.byteLength()], 0..) |*b, i| { const hi = hexValue(hex[2 * i]) orelse return error.InvalidObjectId; const lo = hexValue(hex[2 * i + 1]) orelse return error.InvalidObjectId; b.* = hi << 4 | lo; } return id; }
/// The all-zero object ID, which names no object: git writes it for /// "nothing", as the old value of a ref being created. pub fn zero(f: Format) Id { return .{ .object_format = f, .bytes = @splat(0) }; }
/// The hash's bytes. pub fn slice(id: *const Id) []const u8 { return id.bytes[0..id.object_format.byteLength()]; }
/// Whether two object IDs name the same object. pub fn eql(a: Id, b: Id) bool { return a.object_format == b.object_format and std.mem.eql(u8, &a.bytes, &b.bytes); }
/// Writes the ID in lowercase hexadecimal, as git prints it, into /// `buffer`, and returns the digits written. pub fn toHex(id: Id, buffer: *[max_hex_length]u8) []const u8 { const digits = "0123456789abcdef"; for (id.slice(), 0..) |b, i| { buffer[2 * i] = digits[b >> 4]; buffer[2 * i + 1] = digits[b & 0xf]; } return buffer[0..id.object_format.hexLength()]; }
/// Prints the ID in lowercase hexadecimal, for `{f}`. pub fn format(id: Id, w: *Io.Writer) Io.Writer.Error!void { var buffer: [max_hex_length]u8 = undefined; try w.writeAll(id.toHex(&buffer)); }};
fn hexValue(c: u8) ?u8 { return switch (c) { '0'...'9' => c - '0', 'a'...'f' => c - 'a' + 10, 'A'...'F' => c - 'A' + 10, else => null, };}
/// The kind of an object. The values are the type numbers a pack gives/// them (gitformat-pack(5)).pub const Kind = enum(u3) { commit = 1, tree = 2, blob = 3, tag = 4,
/// The kind an object header, or a tag's `type` line, names. Only the /// four lowercase names are kinds. pub fn fromName(n: []const u8) ?Kind { return std.meta.stringToEnum(Kind, n); }
/// The name of the kind, as an object header and `git cat-file -t` /// write it. pub fn name(k: Kind) []const u8 { return @tagName(k); }};
/// What an object header says: the object's kind and how many bytes of/// contents follow it.pub const Header = struct { kind: Kind, size: u64, /// How many bytes the header took, its terminating NUL included. len: usize,
/// The most bytes a header may take, its NUL included. git reads no /// further than this looking for the end of a loose object's header, /// and calls one that goes past it too long. pub const max_len = 32;
/// Why `parse` refused a header. These are git's own distinctions, /// found by writing loose objects with malformed headers and asking /// `git cat-file` about them: /// /// - `HeaderTooLong`: no NUL in the first `max_len` bytes ("header for /// <id> too long, exceeds 32 bytes"). /// - `MalformedHeader`: no space before the NUL, or a size that is /// not plain decimal ("unable to parse <id> header"): empty, signed, /// with a leading zero, or followed by anything but the NUL. /// - `ObjectSizeOverflow`: a size too big for 64 bits, which git stops /// on ("size_t overflow: …"); the arithmetic that overflowed is the /// diagnostic's detail. /// - `UnknownObjectKind`: a well-formed header naming no kind ("invalid /// object type"). The size is read first, so a header with both a /// bad size and a bad kind is `MalformedHeader`. pub const Error = error{ HeaderTooLong, MalformedHeader, ObjectSizeOverflow, UnknownObjectKind };
/// Reads the header at the start of `bytes`, the beginning of an /// inflated loose object, as strictly as git does. Only the first /// `max_len` bytes are looked at. pub fn parse(bytes: []const u8, diag: ?*Diagnostics) Error!Header { const window = bytes[0..@min(bytes.len, max_len)]; const nul = std.mem.findScalar(u8, window, 0) orelse return error.HeaderTooLong; const text = window[0..nul]; const space = std.mem.findScalar(u8, text, ' ') orelse return error.MalformedHeader; const digits = text[space + 1 ..]; if (digits.len == 0) return error.MalformedHeader; if (digits[0] == '0' and digits.len > 1) return error.MalformedHeader; var size: u64 = 0; for (digits) |c| { if (c < '0' or c > '9') return error.MalformedHeader; const d = c - '0'; const tens = std.math.mul(u64, size, 10) catch { if (diag) |dg| dg.setDetail("{d} * 10", .{size}); return error.ObjectSizeOverflow; }; size = std.math.add(u64, tens, d) catch { if (diag) |dg| dg.setDetail("{d} + {d}", .{ tens, d }); return error.ObjectSizeOverflow; }; } const kind = Kind.fromName(text[0..space]) orelse return error.UnknownObjectKind; return .{ .kind = kind, .size = size, .len = nul + 1 }; }
/// Writes the header for an object of `kind` with `size` bytes of /// contents into `buffer`, and returns it, NUL included. pub fn print(kind: Kind, size: u64, buffer: *[max_len]u8) []const u8 { return std.fmt.bufPrint(buffer, "{s} {d}\x00", .{ kind.name(), size }) catch unreachable; }};
/// Computes an object ID a piece at a time: the header first, then the/// contents.pub const Hasher = union(Format) { sha1: std.crypto.hash.Sha1, sha256: std.crypto.hash.sha2.Sha256,
/// A hasher for `format`, with nothing hashed yet. pub fn init(format: Format) Hasher { return switch (format) { .sha1 => .{ .sha1 = .init(.{}) }, .sha256 => .{ .sha256 = .init(.{}) }, }; }
/// Hashes `bytes` after whatever came before. pub fn update(h: *Hasher, bytes: []const u8) void { switch (h.*) { inline else => |*s| s.update(bytes), } }
/// The object ID of everything hashed. pub fn final(h: *Hasher) Id { var id: Id = .{ .object_format = h.*, .bytes = @splat(0) }; switch (h.*) { .sha1 => |*s| s.final(id.bytes[0..20]), .sha256 => |*s| s.final(id.bytes[0..32]), } return id; }};
/// The object ID of an object of `kind` holding `contents`, as `git/// hash-object` computes it: the hash of its header and contents.pub fn hash(format: Format, kind: Kind, contents: []const u8) Id { var h: Hasher = .init(format); var buffer: [Header.max_len]u8 = undefined; h.update(Header.print(kind, contents.len, &buffer)); h.update(contents); return h.final();}
const testing = std.testing;const fixture = @import("testing/fixture.zig");const fuzz_input = @import("testing/fuzz.zig");
test "an object ID is printed as git prints it, and read back in either case" { for ([_]Format{ .sha1, .sha256 }) |format| { var raw: [max_byte_length]u8 = undefined; for (&raw, 0..) |*b, i| b.* = @intCast(i * 7 + 3); const id: Id = .fromBytes(format, raw[0..format.byteLength()]); var buffer: [max_hex_length]u8 = undefined; const hex = id.toHex(&buffer); try testing.expectEqual(format.hexLength(), hex.len); try testing.expect(Id.eql(id, try .parseHex(format, hex))); var upper: [max_hex_length]u8 = undefined; _ = std.ascii.upperString(&upper, hex); try testing.expect(Id.eql(id, try .parseHex(format, upper[0..hex.len]))); try testing.expectError(error.InvalidObjectId, Id.parseHex(format, hex[1..])); upper[3] = 'g'; try testing.expectError(error.InvalidObjectId, Id.parseHex(format, upper[0..hex.len])); }}
test "an object hashes to the ID git gives it" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); try repo.commitFile("a", "hello\n", "first"); const Case = struct { kind: Kind, rev: []const u8 }; for ([_]Case{ .{ .kind = .blob, .rev = "HEAD:a" }, .{ .kind = .tree, .rev = "HEAD^{tree}" }, .{ .kind = .commit, .rev = "HEAD" }, }) |case| { const want = try repo.output(&.{ "rev-parse", case.rev }); defer gpa.free(want); const contents = try repo.output(&.{ "cat-file", case.kind.name(), case.rev }); defer gpa.free(contents); const got = hash(repo.object_format, case.kind, contents); var buffer: [max_hex_length]u8 = undefined; try testing.expectEqualStrings(std.mem.trimEnd(u8, want, "\n"), got.toHex(&buffer)); }}
test "a header is read as git reads it" { const Case = struct { bytes: []const u8, want: (Header.Error || error{})!Header }; const ok = struct { fn f(kind: Kind, size: u64, len: usize) Header { return .{ .kind = kind, .size = size, .len = len }; } }.f; // What `git cat-file -t` said of loose objects with each of these // headers (see the differential tests in tools/zig_git.zig, which ask // it again). const cases = [_]Case{ .{ .bytes = "blob 3\x00abc", .want = ok(.blob, 3, 7) }, .{ .bytes = "blob 0\x00", .want = ok(.blob, 0, 7) }, .{ .bytes = "tag 3\x00abc", .want = ok(.tag, 3, 6) }, .{ .bytes = "blob 18446744073709551615\x00", .want = ok(.blob, std.math.maxInt(u64), 26) }, .{ .bytes = "blob 3\x00abc", .want = error.MalformedHeader }, .{ .bytes = "blob 03\x00abc", .want = error.MalformedHeader }, .{ .bytes = "blob +3\x00abc", .want = error.MalformedHeader }, .{ .bytes = "blob -1\x00abc", .want = error.MalformedHeader }, .{ .bytes = "blob 3 \x00abc", .want = error.MalformedHeader }, .{ .bytes = "blob\x00abc", .want = error.MalformedHeader }, .{ .bytes = "blob \x00abc", .want = error.MalformedHeader }, .{ .bytes = "blob\t3\x00abc", .want = error.MalformedHeader }, .{ .bytes = "foo 03\x00", .want = error.MalformedHeader }, .{ .bytes = "Blob 3\x00abc", .want = error.UnknownObjectKind }, .{ .bytes = " 3\x00abc", .want = error.UnknownObjectKind }, .{ .bytes = "ofs-delta 3\x00abc", .want = error.UnknownObjectKind }, .{ .bytes = "blob 3abc", .want = error.HeaderTooLong }, .{ .bytes = "", .want = error.HeaderTooLong }, // 32 bytes, NUL included, and then 33. .{ .bytes = "aaaaaaaaaa" ++ "aaaaaaaaaa" ++ "aaaaaaaaa" ++ " 3\x00", .want = error.UnknownObjectKind }, .{ .bytes = "aaaaaaaaaa" ++ "aaaaaaaaaa" ++ "aaaaaaaaaa" ++ " 3\x00", .want = error.HeaderTooLong }, .{ .bytes = "blob 99999999999999999999\x00", .want = error.ObjectSizeOverflow }, }; for (cases) |case| { const got = Header.parse(case.bytes, null); if (case.want) |want| { try testing.expectEqual(want, try got); } else |want| { try testing.expectError(want, got); } } var diag: Diagnostics = .{}; try testing.expectError(error.ObjectSizeOverflow, Header.parse("blob 18446744073709551616\x00", &diag)); try testing.expectEqualStrings("18446744073709551610 + 6", diag.detail()); try testing.expectError(error.ObjectSizeOverflow, Header.parse("blob 111111111111111111111\x00", &diag)); try testing.expectEqualStrings("11111111111111111111 * 10", diag.detail());}
test "fuzz: object headers" { try testing.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [64]u8 = undefined; const bytes = fuzz_input.bytes(smith, &buffer); var diag: Diagnostics = .{}; const header = Header.parse(bytes, &diag) catch return; try testing.expect(header.len <= Header.max_len); var printed: [Header.max_len]u8 = undefined; try testing.expectEqualStrings(bytes[0..header.len], Header.print(header.kind, header.size, &printed)); } }.one, .{ .corpus = &.{ "blob 3\x00abc", "commit 245\x00tree ", "blob 18446744073709551615\x00" } });}
test "fuzz: hexadecimal object IDs" { try testing.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [80]u8 = undefined; const bytes = fuzz_input.bytes(smith, &buffer); for ([_]Format{ .sha1, .sha256 }) |format| { const id = Id.parseHex(format, bytes) catch continue; var hex: [max_hex_length]u8 = undefined; try testing.expect(std.ascii.eqlIgnoreCase(bytes, id.toHex(&hex))); } } }.one, .{ .corpus = &.{ "4b825dc642cb6eb9a060e54bf8d69288fbee4904", "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321" } });}